12k
All articles

在开发环境中用命名 URL 取代端口号

使用 .localhost、反向代理或 portless 将 localhost 端口替换为命名 URL,避免端口冲突、Cookie 串扰和标签页弄错。

OpenReplay Team
OpenReplay Team
在开发环境中用命名 URL 取代端口号

localhost 域名是一种人类可读的主机名,例如 app.localhost,它会解析到 127.0.0.1,从而让每个本地服务拥有一个稳定的地址,而不是一个不断变化的端口号。

你大概经历过这样的时刻:三个开发服务器同时在跑,你切回 localhost:3000 想确认一处修复,结果盯着你的却是昨天那个项目。把 localhost:3000 换成 app.localhost 能一次性解决一堆日常烦恼(端口冲突、URL 漂移、cookie 串扰,以及”切错标签页”的问题),因为每个应用都拥有了自己的主机名,随之也拥有了各自隔离的浏览器作用域。本文介绍三种实现方式:浏览器内置的 .localhost TLD、自建反向代理,以及 portless——一个由 Vercel Labs 专门打造的本地代理工具。

核心要点

  • .localhost TLD 已被 RFC 6761 保留用于回环地址,因此在 Chrome、Firefox 和 Edge 中,该 TLD 下的任何名称都会解析到 127.0.0.1,无需添加 hosts 文件条目。而 Safari 会交由系统解析器处理,可能仍需手动配置。
  • 由于浏览器按主机(host)划分 cookie 作用域并忽略端口,app.localhostapi.localhost 彼此隔离,而 localhost:3000localhost:3001 则共享同一个 cookie jar。
  • 仅靠 .localhost TLD 并不能去掉端口;你的应用仍在某个端口上监听,因此你需要一个反向代理把主机名映射到该端口。
  • portless(Vercel Labs 出品,目前仍处于 1.0 之前)通过 PORT 环境变量为每个应用分配 4000–4999 范围内的临时端口,并将一个稳定的 name.localhost URL 路由到它,默认启用 HTTPS 和 HTTP/2。
  • 把稳定的命名 URL 记录在 agents 文件中,可以让 AI 编码工具直接访问正确的服务,而不必在 3001 和 8080 之间瞎猜。

为什么命名 URL 优于端口号?

一旦你同时运行多个服务,基于端口的本地开发就会以可预见的方式出问题。在已被占用的端口上启动第二个应用,Node 会抛出 EADDRINUSE。会自动递增端口的框架虽然避开了崩溃,却引入了漂移:你的博客今天在 localhost:3001,明天就跑到了 localhost:3002,书签因此失效,而 localhost:3000 的浏览器历史记录则变成一堆互不相关项目的混杂列表,根本无从查找。关掉一个服务器,在腾出的端口上启动另一个,你此前留着没关的标签页就会悄无声息地开始服务另一个项目——这就是”切错标签页”的问题。

更隐蔽的故障是状态串扰。浏览器按主机划分 cookie 作用域且不考虑端口,因此 localhost:3000localhost:3001 会写入同一个 cookie jar。一个应用的会话状态会泄漏到另一个应用中。命名子域名从源(origin)层面解决了这个问题:app.localhostapi.localhost 是不同的主机名,因此能干净地隔离 cookie;而且由于同源策略以协议、主机和端口为键,它们同样隔离了 localStoragesessionStorage微软关于该 TLD 的指南也表达了同样的观点:为每个本地应用赋予各自的名称,可以让 cookie 等按名称划分作用域的资源彼此分离,同时地址栏中的名称让你一眼就能看出正在查看的是哪个应用。

什么是 .localhost TLD?

最简单的命名 URL 机制就内置在你的浏览器里。RFC 6761 将 .localhost TLD 及其下的所有名称保留给回环地址,这就是为什么 app.localhost 无需任何配置就能在 127.0.0.1 上响应。Chrome、Firefox 和 Edge 在内部处理这一解析,把任何 *.localhost 名称映射到 127.0.0.1::1,因此这类名称相当于当前在 localhost 上提供服务的任何内容的别名。需要留意的是 Safari:它会把名称交给系统 DNS 解析器,而并非所有解析器配置都会响应 .localhost 子域名,因此在 Safari 上你可能需要添加 /etc/hosts 条目。

不过有个前提:仅靠这个 TLD 并不能去掉端口。你的应用仍在 :3000 上监听,而不带端口的 app.localhost 实际访问的是 app.localhost:80,那里并没有任何服务在监听。要真正省掉端口号,你需要在 80 或 443 端口上运行一个反向代理,读取 Host 头并转发到应用的真实端口。

自建方案:hosts 文件加反向代理

你可以用已经熟悉的组件拼出命名 URL。在 /etc/hosts 中添加一个主机名(或者直接依赖 .localhost 的自动解析),然后运行一个反向代理,把该名称映射到开发服务器的端口。Caddy 的配置堪称极简:

app.localhost {
  reverse_proxy localhost:3000
}
api.localhost {
  reverse_proxy localhost:8080
}

Caddy 会自动签发本地 TLS 证书;nginx 和 Traefik 也能做到同样的事,只是配置更繁琐。对于通配符本地域名,dnsmasq 可以把整个 *.test 空间解析到 127.0.0.1,这样你就不必为每个名称单独添加 hosts 条目。此外,每个开发服务器仍需固定其主机和端口(Vite 通过 server.hostserver.port,webpack 通过 devServer),这样代理才有稳定的转发目标。

代价是维护成本。你需要维护代理配置、证书信任、hosts 条目以及各项目的端口分配,并在服务增减时手动保持这四者同步。对于一两个长期存在的应用,这没什么问题。但在 monorepo 里,它本身就变成了一项杂活。

portless:开箱即用的命名 URL

portless 是一个把整条链路自动化的本地代理。你只需给开发命令加个前缀,把 next dev 变成 portless run next dev;或者直接运行 portless,让它从 package.json、git 根目录或当前目录推断应用名称。代理会自动启动,分配 4000–4999 范围内的空闲端口,通过 PORT 环境变量注入,并把 https://name.localhost 路由到它。对于忽略 PORT 的框架,如 Vite、Astro、Angular 和 Expo,portless 会替它们传入正确的 --port 参数,并在需要时附带相应的 --host 参数。

0.15.x 版本中,portless 默认在 443 端口启用带 HTTP/2 的 HTTPS,并在首次运行时生成并信任一个本地证书颁发机构。 由于绑定 443 端口需要 root 权限,它会在 macOS 和 Linux 上通过 sudo 自动提权;如果你跳过了提示,可以用 portless trust 重新添加 CA。此前那些提到需要手动开启 --https 标志、默认端口为 :1355 的文章描述的是已被取代的旧版本。HTTP/2 在本地开发中之所以有帮助,原因很具体:浏览器对同一主机最多只保持六个 HTTP/1.1 连接,因此一个需要交付数百个未打包文件的开发服务器最终只能排队发送,而单个 HTTP/2 连接可以同时承载全部请求。portless 要求 Node.js 24 或更高版本。

有几项功能在较大的项目中格外好用。像 api.myapp.localhost 这样的子域名可以用来组织微服务;在 monorepo 根目录放一个 portless.json 即可自动发现工作区内的各个包。对于无法更改端口的固定端口服务(例如 Docker 容器),portless alias <name> <port> 可以把一个命名 URL 映射到它;而 PORTLESS=0 则会完全绕过代理,适用于 CI 或快速测试。如果你想使用自定义 TLD,portless 推荐 .test(RFC 6761 同样保留了它),并提醒避开另外两个:.local 会与 mDNS 和 Bonjour 冲突,而 .dev 归 Google 所有,后者通过 HSTS 强制其升级到 HTTPS。

为什么稳定的本地 URL 对 AI 编码代理很重要

AI 编码代理在端口问题上会犯和人类一样的错,只不过是静默发生的:它们会硬编码此前在上下文中看到的某个数字,或者干脆猜错。一个能从 AGENTS.md 文件中读到固定的 https://api.myapp.localhost 的代理,总能命中正确的服务,而不会在不同会话之间在 3001 和 8080 之间反复摇摆,也不必打断你来发问。这体现了开发工具领域的一个普遍转变:稳定的端点是自动化的基础设施。portless 自带 skill 文件,而 0.15.x 版本还新增了 Markdown 文档页面和 llms.txt 索引,让其 URL 对代理开箱即可发现。

如何选择方案

仅用 .localhost TLDTLD + 反向代理portless
是否去掉端口?
额外工具Caddy/nginx/Traefik一次全局安装
HTTPS手动配置由代理提供默认启用
monorepo 自动发现
对代理友好部分部分是(skill 文件、llms.txt
上手成本最低中等(需手动同步)

一句话决策:如果你不想引入任何新工具,也不介意维护配置,就选内置 TLD 加反向代理;如果你希望命名 URL 在众多服务、monorepo 或 AI 代理场景下开箱即用,就选 portless。

具名、稳定、人类可读的本地 URL 绝对优于端口号,而且你可以在几分钟内完成迁移:今天就写一个两行的 Caddyfile,或者给一个开发脚本加上 portless 前缀,从此不再为 EADDRINUSE 烦心。

常见问题

我需要把 .localhost 子域名添加到 /etc/hosts 文件中吗?

不需要,至少在 Chrome、Firefox 和 Edge 中不需要。由于 RFC 6761 将该 TLD 保留用于回环地址,这三款浏览器会自行把 .localhost TLD 下的任何名称解析到 127.0.0.1,因此 app.localhost 和 api.localhost 无需任何配置即可使用。Safari 是例外,因为它把查询交给系统 DNS 解析器,而并非所有解析器配置都会响应 .localhost 子域名。如果某个名称在 Safari 中无法加载,请为其添加 /etc/hosts 条目。

使用 .localhost 域名能去掉开发服务器的端口号吗?

不能。.localhost TLD 只负责把主机名解析到 127.0.0.1;你的应用仍在原来的端口上监听,因此不带端口的 app.localhost 实际访问的是 app.localhost:80,而那里并没有服务在运行。要真正省掉端口号,你需要在 80 或 443 端口上运行一个反向代理,读取 Host 头并转发到应用的真实端口。这正是 Caddy 或 portless 这类工具所自动化的工作。

为什么 cookie 会在 localhost:3000 和 localhost:3001 之间泄漏,而在 app.localhost 和 api.localhost 之间不会?

浏览器按主机划分 cookie 作用域并忽略端口,因此 localhost:3000 和 localhost:3001 拥有相同的主机 localhost,也就共享同一个 cookie jar。命名子域名的主机各不相同,所以 app.localhost 和 api.localhost 的 cookie 彼此隔离。由于同源策略以协议、主机和端口为键,不同的主机名同样能干净地隔离 localStorage 和 sessionStorage,而基于端口区分的源做不到这一点。

portless 需要哪个 Node.js 版本,不用 sudo 能运行吗?

portless 需要 Node.js 24 或更高版本。在 macOS 和 Linux 上,它会在首次运行时通过 sudo 自动提权,因为为 HTTPS 绑定 443 端口需要 root 权限。HTTPS 开箱即以 HTTP/2 运行,portless 会在首次运行时创建并信任一个本地证书颁发机构;如果你跳过了初始提示,可以稍后使用 portless trust 来添加该 CA。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.