Local Web Surface
把一个“本地页面”交付成可长期使用的产品入口:固定 *.localhost URL、登录后自动可用、可选 .app 启动器、真实运行验收。
此 Skill 编排已有代码与系统能力,不规定前端框架。默认优先复用现有 loopback daemon;只有证据证明生命周期、权限或故障域必须隔离时,才新增监听进程。
适用性门槛
请求至少要包含一个持久 surface 信号:稳定 *.localhost 身份、loopback daemon/gateway、登录自启、共享 listener,或为持久本地 HTTP surface 提供唤醒/打开能力的 macOS .app launcher。只包装 https://... 公网或内网页面的 .app 不属于本 Skill。普通 dev server 上的页面/CRUD 实现不使用本 Skill;只需打开、操作或截图现有 URL 时交给 Browser/agent-browser。站点扫描与修复应交给当前环境中已安装且匹配的审计流程;不要硬路由到未验证存在的 Skill。
先定义可验证结果
开始前写出以下合同:
Local Web Surface Contract
- Surface: <name>
- Canonical URL: http://<name>.localhost:<port>/
- Data owner: <repo/module>
- Transport owner: <existing daemon or new service>
- Read boundary: <what may be displayed>
- Publish boundary: <none or what may leave the machine>
- Write boundary: <none or exact mutations>
- Access/threat boundary: <trusted local user/processes, or exact authentication/authorization>
- Launcher: <none or App name>
- Done signals: <evidence selected by Change surfaces; Host/handler-only = focused tests + diff scope + curl 200/421/404>
若用户已授权自主完成,可从代码和本机状态解析这些值,不重复询问。若选择会改变数据真相源、公开范围、访问/认证边界或占用未知服务端口,先停在一个决策问题上。
第一性原理
先从约束推导方案:
- 页面必须有稳定地址,用户不应记随机端口或手动启动命令。
- 数据必须只有一个真相源;UI、CLI 与 Agent 不得各写一份副本。
- 当
Write boundary非none时,写入必须有一个受控端口;浏览器和 Agent 不直接拼 YAML/JSON。 - 本地服务只监听 loopback,不能因为“只在自己电脑上”就省略 Host、Origin、输入和路径检查;loopback、Host 与 Origin 都不是调用者身份认证。
- 多个页面共享同一生命周期时,一个 listener + 精确 Host 分发比多个常驻 daemon 更实用。
.app只负责唤醒/打开,不承载业务和第二份服务生命周期。
.localhost 及其子域由客户端作为 loopback 特殊域处理;不要为常规 *.localhost 别名修改 /etc/hosts。实现前仍应以当前系统上的 DNS/curl 结果验证,而不是只凭规范推断。
DDD 与依赖方向
把职责放回拥有它的 bounded context:
| 边界 | 拥有什么 | 不拥有什么 |
|---|---|---|
| Domain/Data owner | 数据模型、校验、所需 repository、内容过滤、HTML/DTO 产物 | 端口、LaunchAgent、全局 Host 表 |
| Local application | 页面 handler、应用路由、用例编排 | daemon 生命周期、其他 surface 的内部实现 |
| Loopback gateway | TCP listener、精确 Host 分发、body 上限、响应桥接 | surface 业务规则、数据文件格式 |
| Launcher | 登录自启、.app、打开 canonical URL |
第二个 server、数据写入、业务状态 |
依赖方向固定为:gateway 依赖一个窄的 value contract;data owner 不反向依赖 gateway。跨仓库集成时,传递普通 request/response 值或加载一个稳定 handler 入口,不把 domain 类和 storage 细节复制进 gateway。
高内聚体现在:一个数据变更的校验、并发和持久化都留在 repository;低耦合体现在:新增 surface 只增加 Host 映射、handler 入口和 launcher spec,不修改其他 surface 的业务分支。
Change surfaces(变更面)
先按实际 diff 选择最小验收面,不因 Skill 覆盖完整产品生命周期就默认执行所有检查:
| 变更面 | 典型改动 | 最小必需证据 |
|---|---|---|
| Host/handler-only | exact Host 映射、只读 handler、跨 surface 路由 | focused gateway/handler tests、diff scope、curl 的 canonical 200/未知 Host 421/跨 surface 404 |
| Lifecycle/launcher | listener 启动方式、LaunchAgent、installer、.app opener |
加做 plist、tracked PID、单 listener、重复安装和 launcher 参数验收 |
| UI | 页面结构、交互、响应式 | 加做合同相关的真实浏览器、Console、桌面与移动 viewport |
| Write/data | mutation、repository、文件持久化 | 加做 schema、ETag、并发、atomic write、临时 fixture 与失败路径 |
| Public projection | local/public catalog 或 sync | 加做临时 public sync 与 private/local-only 排除 |
Host/handler-only 仅在现有 listener 与生命周期不变、没有 UI/写入/发布改动时成立;不要为它重装 LaunchAgent、重复查 PID、跑双 viewport 或执行全仓验证。每轮改动后重新看 diff;一旦跨入其他行,就只补对应行的证据。仓库自身明确要求的检查仍然优先。
前置检查
编辑前完成:
- 读取每个涉及仓库的
AGENTS.md和架构/测试文档。 - 分别记录
git status --short;保留现有 dirty changes,禁止重置或顺手整理。 - 先分类
Change surfaces。只有涉及 lifecycle/launcher 时,才用lsof -nP -iTCP:<port> -sTCP:LISTEN、launchctl print、进程命令和 launcher 代码确认真实生命周期。 - 只有依赖或 runner 相关改动时,才从
package.json、lockfile 与本机二进制确认 Node、runner、依赖版本。 - 只查当前变更面会触及的现有 server、handler、CLI、
.app、LaunchAgent、数据文件、writer 或发布过滤;优先扩展现有模块。 - 对版本敏感 API 查官方文档或项目锁定版本的文档,并用运行结果验证。
设计门禁
在写代码前形成最小 surface 表:
| Surface | Exact Host | Owner | Reads | Writes |
|---|---|---|---|---|
| existing | existing.localhost:<port> |
existing repo | existing data | existing API |
| new | new.localhost:<port> |
data repo | explicit allowlist | explicit commands or none |
必须明确:
- gateway 使用 exact Host allowlist;未知、畸形或错误端口返回
421,不能回落到默认管理页。 - 每个 Host 只看见合同列出的路由;不能访问其他 surface 的页面或 API。
- 当
Read boundary涉及公开/私有内容时,必须把“本地可见”和“允许发布”写成两个独立投影;意图不明确或页面可能被他人访问时,本地 reader 默认 fail closed,只展示显式公开内容。 private/local-only只描述“不进入 public projection”,不承诺保密。loopback 服务会替调用方读取文件;同机其他账户或进程可能直接发 HTTP。Access/threat boundary若不信任它们,必须加入真实认证/授权,或停止暴露该内容。- 仅当
Write boundary非none时,写请求才至少检查 canonicalOrigin、Content-Type: application/json、应用标记 header、body 上限和If-Match。Origin 与应用 header 主要约束浏览器写入,不是客户端身份。 - 页面、CLI 与 Agent 等多个入口读写同一份数据时,统一路由到一个唯一 service/repository writer;禁止绕过它直接编辑。只有无法消除多个 OS 进程直接写入时,才增加跨进程锁。
内容型 Reader 的双投影合同(条件)
只在 surface 读取带发布状态的文章或文档时启用本节。不要把 public 同时当作本地读取权限和发布资格,除非用户明确要求两者完全相同。
当用户确认这是个人 loopback workspace、Access/threat boundary 允许当前本机调用者,并要求本地查看公开与私密成文时:
- Local catalog 只扫描合同指定的文章根,例如
posts/**/*.md;接纳显式布尔public: true与public: false。 - Public projection 或 sync 仍只接纳
public === true。不要为满足本地预览去放宽发布脚本。 - 缺失或非布尔
public的记录既不展示也不发布,并只报告相对路径和元数据错误,不返回正文。 - 研究素材、事实底座和助理数据(例如
raw/、notes/、digests/)不属于文章 catalog;只有独立 surface 合同明确纳入时才读取。 - DTO 投影稳定
visibility: public | private、目录分类和规范化 tags。页面提供all/public/private、category、tag 与搜索的组合筛选。 - 私密卡片与详情必须明确标记 local-only,并说明 public sync 会排除它,防止“看得见”被误解为“会发布”。
当页面不是个人 loopback workspace、用户没有确认可显示私密内容,或访问边界不能保护敏感内容时,继续 fail closed,仅展示显式 public: true。若 public: false 代表真正机密而非发布状态,必须先加认证/授权,不能把 local-only 标签当作保护。
TDD 实施顺序
行为变更按 RED → GREEN → REFACTOR:
- Domain tests:覆盖合同中的数据规则;只读 surface 不虚构 mutation 或状态机。
- Mutation/concurrency tests(条件):仅当
Write boundary非none时覆盖强 ETag、缺If-Match、stale revision、同进程串行、revision 重检和临时文件清理。只有不可消除的多个 OS 进程会直接写同一文件时,才增加跨进程 lock、崩溃恢复、活 owner 检查和 replacement-lock 测试。 - Catalog tests(条件):仅当读取发布型内容时分别覆盖 local catalog 与 public projection、稳定 ID、排序、分类/tags/visibility、frontmatter 错误不泄露正文、Markdown XSS、图片 traversal/symlink escape。
- Handler tests:页面 shell、合同列出的用例、跨 surface 404、安全 guard、稳定错误码。
- Gateway tests:Host allowlist、bridge failure closed,以及合同需要的 body limit。
- Lifecycle/launcher tests(条件):只有对应变更面存在时才覆盖 launcher specs、plist、tracked PID 与重复安装生命周期。
- Runtime checks:只有适用于该合同的上述测试通过后才安装/重启真实服务。
不要先写整套实现再补快照测试。每个发现的运行时 bug 先补能失败的回归测试,再改实现。
文件型 CRUD 的最低安全线(条件)
只在 Write boundary 非 none 且存储为 YAML/JSON 单文件时启用本节。repository 至少做到:
- 在系统边界严格校验根结构、允许字段、值域和真实日期。
- 用内容 hash 生成 strong ETag;所有 mutation 要求
If-Match。 - 唯一 writer 内的写操作串行,并在替换前重新检查 revision。优先让 CLI、页面与 Agent 调用这个 writer,避免制造多进程写拓扑。
- 只有证据证明多个 OS 进程必须直接写同一文件时,才使用 crash-recoverable 独占 lock。优先复用成熟的 atomic directory + heartbeat/stale lease 或项目已验证的 advisory-lock,并让所有 writer 使用完全一致的 lock path、stale/update/retry 参数和 compromised 处理。
- 若不得不自建 owner lock,记录随机 token、PID、进程启动身份和时间;活 lease 或身份匹配的 live owner 绝不接管。stale recovery 必须串行,并让所有 writer 在接管窗口前后检查带 owner identity 或 lease/heartbeat 的 recovery guard,防止两个 reaper 删除刚创建的新 lock。
- 在目标文件同目录写唯一 temp file,flush/sync,重新检查 revision,再 atomic rename。
finally只清理自己 token 对应的 temp/lock;删除前重新核对 owner,绝不删除已被新 owner 替换的 lock。- 多进程 writer 场景的回归测试必须覆盖:持锁子进程异常退出后 writer 可恢复;live owner 不被接管;旧 owner 清理时不能删除新 owner 的 lock。唯一 writer 场景不虚构跨进程 lock 测试。
- mutation 返回新 revision;冲突返回
412,缺前置条件返回428。 - 自动化 CRUD 验收使用临时 fixture。不要为了测试 UI 改写用户真实数据,除非用户明确授权且恢复策略可靠。
页面实现
优先交付无构建依赖的响应式页面,除非现有应用已有框架:
- 用户内容进入 DOM 时使用
textContent;只有经过受控 sanitizer/安全 Markdown renderer 的 HTML 才可innerHTML。 - 表单和按钮有可访问名称、键盘路径和
aria-live状态。 - mutation 失败保留用户输入;
412明确提示 reload/retry。 - 若合同是 Todo CRUD,最少具备 create、list/read、rename/update、complete/reopen、delete 与确认。
- 若合同是内容 Reader,最少具备合同要求的文章列表、搜索、目录分类、标签、可见性筛选、deep link、正文与安全图片;只有合同允许私密内容时才显示 private 状态。
- 同时验收桌面和约
390x844的移动 viewport。
Gateway 与 launchd
gateway 只监听 127.0.0.1(或明确验证过的 loopback),并把传入 header 缩到 handler 实际需要的集合。loopback/Host 隔离不等于认证;按 Access/threat boundary 对敏感读取和写入增加真实授权。body 超限时排空请求,不要销毁底层 socket;否则客户端可能收不到稳定 413。
macOS 登录服务遵守:
- 一个
~/Library/LaunchAgents/<label>.plist,使用RunAtLoad+KeepAlive。 - LaunchAgent 必须拥有最终长期 Node listener。优先让
ProgramArguments直接执行node --import tsx <absolute-cli.ts> web;只做环境准备且最后exec node ...的 shell 也可保持同一 PID。不要让 LaunchAgent 只跟踪会再 spawn 子进程的tsxCLI wrapper,否则bootout可能留下孤儿 listener。 WorkingDirectory、logs 与PATH使用绝对值;plutil -lint必须通过。- 多个
.app是薄 opener:exec <cli> open-web <surface>。每个 app 有独立 bundle ID/name,但不启动第二份 daemon。 - installer 必须可重复执行。至少连续安装两次并确认仍只有一个 listener、LaunchAgent
state = running、error log 无EADDRINUSE。
真实验收
先按 Change surfaces 选择证据。Host/handler-only 的完整默认验收只有三项:focused gateway/handler tests;git diff --check 与范围核对;临时或既有安全 listener 上用 curl --noproxy '*' 证明 canonical Host 200、未知 Host 421、跨 surface 路由 404。这类改动默认不要求 full tests/typecheck/build、LaunchAgent 重装、PID/lsof、浏览器或 public sync。
diff 扩大后,按新增变更面补充以下检查,不机械执行无关项:
- 所有变更都跑涉及仓库的 focused tests;只有仓库规则要求或 diff 涉及共享类型、构建、依赖时才加 full tests、typecheck/build。单仓库任务不得虚构第二个仓库。
git diff --check,确认没有范围外文件、secret、临时 lock 或 fixture。- Lifecycle/launcher:
plutil -lint涉及的 plist;检查每个变更的.app/Contents/MacOS/launcher的 surface 参数。 - Lifecycle/launcher:
launchctl print gui/<uid>/<label>显示state = running且有唯一 tracked PID。 - Lifecycle/launcher:
lsof证明目标端口只有一个 loopback listener,且 tracked PID 就是 listener PID,而非短命 wrapper。 curl --noproxy '*':每个 canonical Host 返回正确页面/API;未知 Host421;跨 surface 路由不泄露;合同包含 body guard 时再证明超大 body 稳定413。- UI:用真实浏览器打开变更的 URL,按合同检查 Console、快照与功能;只有合同包含搜索/deep link 或 CRUD 时才验收它们;同时检查桌面与移动 viewport。
- Public projection:仅对发布型改动跑临时 target sync,证明
public:false与合同列出的其他本地内容没有进入公共产物;若本地 reader 允许私密内容,再用 API 和浏览器分别证明它们确实进入本地列表、详情、搜索/筛选与 deep link。
若 UI CRUD 已由临时 repository/handler 集成测试覆盖,真实浏览器验收可以只读;不要用“为了验收”作为改动用户数据的理由。
失败恢复
EADDRINUSE:先解析 tracked PID、listener PID、父子/进程组和完整命令,并搜索应用是否主动spawn/fork/daemonize;只终止已验证属于该服务的进程。修 installer 后重复安装验证,不只手工 kill 一次。- dynamic handler 缺失或响应越界:gateway 返回稳定
503,不暴露本地绝对路径和 stack。 - lock timeout:返回可重试错误;按选定协议用 heartbeat/stale lease,或 token、PID 与进程启动身份判断 live/stale。只有 crash-recovery 协议能证明 owner 已失效时才回收;无法证明就保留 lock 并报告,不按未经 heartbeat 约束的文件年龄猜删。
- 浏览器能开但 mutation 失败:检查真实
Origin、Host、ETag、header 与 socket 行为,不用关闭安全 guard 取巧。 - local/public catalog 有错误:报告相对路径和元数据问题,不返回文章正文;先核对当前 surface 的 Read boundary 与 Publish boundary,不用删掉私密投影来掩盖发布边界缺失。
完成报告
只在真实信号齐全时声明完成:
Local Web Surface Result
- URLs: <canonical URLs>
- Launchers: <none | apps + LaunchAgent when that change surface exists>
- Data ownership: <source of truth + only writer>
- Security boundaries: <Host/Origin/access-auth/local visibility/publication/path controls>
- Verification: <exact evidence selected by Change surfaces>
- User data touched during QA: no | <explicit details>
- UNVERIFIED: none | <blocked check and reason>
未经用户授权,不 stage、commit、push、deploy 或改写真实业务数据。