Web app-owned routes are document-relative
Web 应用自有路由相对文档寻址
Every browser reference to a shell-owned route was origin-root absolute: `/api/...` RPC, `/plugins/??...` plugin bundles, the Remote stream mux, the HMR event stream, and the launch entry's token-cleanup redirect to `/`. One listener served behind a prefix-stripping proxy (a mount such as `https://host/tools/dsh/` that forwards `/tools/dsh/...` as `/...`) therefore saw those requests at the origin root, where no rout
English
Problem
Every browser reference to a shell-owned route was origin-root absolute: /api/... RPC, /plugins/??... plugin bundles, the Remote stream mux, the HMR event stream, and the launch entry's token-cleanup redirect to /. One listener served behind a prefix-stripping proxy (a mount such as https://host/tools/dsh/ that forwards /tools/dsh/... as /...) therefore saw those requests at the origin root, where no route answers them: the shell, its plugin bundles, and its streams all missed. A single bundle has to serve both the origin root and any mount without a second build.
The mount cannot be recovered from the transport. A configured base URL makes it deployment input, forwarded-prefix inference makes a request header authoritative for routing, and response rewriting in the proxy reimplements the same fact in fragments. The one place that already knows the mount is the served document itself.
Decision
The served HTTP shell owns its document base. Its index carries exactly one <base href="./">, spliced in after the opening head tag once the index taps have run, so it precedes the boot manifest, every plugin-resource row, and any markup a tap inserted. The base freezes the entry directory: /mount/ and /mount/index.html are the supported entries, and a document URL that later moves (an in-page pushState) does not move the base. Nothing infers a prefix, so deep direct loads and reloads are not entry points; a bare mount still needs the proxy's own normalization to its slash form.
Browser references to those routes are relative to that document — api/x, plugins/x — while server route keys stay absolute pathnames: the RPC channel key /api, the webServer registration paths, and the response tables keyed by the combo route /plugins/??...&rev=.... A shared constant that names a key (*_PATH, *_ENDPOINT) is stripped at the boundary (KEY.slice(1)) before browser code uses it, and the browser form lives beside it as *_ROUTE.
The Host composes browser references in one place per producer. Module graph rows and batch descriptors carry the document-relative combo reference; the source-map trailer carries the combo query alone, because a script's source-map reference resolves against that script's own directory (.../plugins/) rather than the document. The response table stays keyed by the absolute route, which is also what a request reaches after the proxy strips its prefix.
The launch entry follows the same rule. authenticatedUrl adds the process token to the caller's URL and keeps its authority and mount, and the token exchange redirects to ./, which removes the query token while preserving the request URL's directory. The Host/Origin fence and the cookie rules are unchanged.
Consumers that need an absolute URL resolve against document.baseURI: the Gateway when it constructs its WebSocket URL and selects ws: or wss:, and the Inspector when it locates its own bundle from the boot graph.
Desktop is the other document owner. Its window loads dsh-app://app/, an origin that serves the same dist at that root and forwards every app-owned path to the shell-owned Host, so its document directory already is the root and no base row is injected for it; it names that Host origin for the Gateway through the transport's streamBaseUrl instead. Its resources live at that root, which is also why the served HTTP shell's ./ does not apply to it.
Alternatives considered
A configured base URL as the routing base (publicUrl). Rejected: the document already knows its own URL, so this adds a second, drift-prone source for a fact the page can read, and it would have to be plumbed to every consumer instead of one shell decision. The advertised public URL remains an operator-facing print/open input, not a routing input.
Inferring the mount from a forwarded-prefix header. Rejected: it makes a request header authoritative for routing, so any client that sets it could move app-owned routes, and the proxy contract becomes server-visible configuration.
A shared Connection.resolveUrl or routes base. The HTTP carrier's document base and the worker tunnel's Host-root mapping differ. Resolving every target in Connection would prepend a static preview path to worker-local routes or lose a served mount. Each carrier resolves its own targets; UI resource consumers use the document base.
An absolute / redirect with the proxy rewriting Location. Rejected: the browser already knows the request's directory, and a configured redirect target cannot be right for both the loopback entry and a proxy mount.
Consequences
One bundle serves the origin root and any mount, and the served document is the single source of the mount. The costs are explicit: the supported entries are only the dist root and the configured index path, a bare mount depends on the proxy's slash normalization, and a reload of a deep in-page path re-enters at that path rather than the frozen one.
Routes owned by feature packages (open-in-app, deliverables, session-log export, uploads, markdown media) follow the same rule through their own *_ROUTE constants; the static gate that keeps browser sources on this rule is recorded with those changes.
Testing
The frontend-static real-composition test serves the index through the Loader and asserts one <base href="./"> that precedes an injected plugin-resource row and tap markup. The client-modules node-half suite asserts that graph rows, batch descriptors, and the source-map trailer are document-relative while the response table still serves them by route, and the client test roster plus the assembled-boot fixture carry the same relative form. browser-auth.host.spec.ts covers the ./ redirect and the authority- and mount-preserving authenticatedUrl; frontend-static.spec.ts and apps/cli/tests/web-auth.e2e.ts run the exchange through a real Loader composition and the built CLI.
apps/web/tests/public-mount.e2e.ts is the browser proof: a real Chromium reaches one listener through the prefix-stripping proxy, the mounted WebSocket streams both ways, a pushState to a deeper path leaves document.baseURI at the mount, and a document-relative fetch('api/session/create') still succeeds beneath it.
中文
问题
浏览器对外壳自有路由的每一处引用都以源站根绝对形式书写:/api/... RPC、/plugins/??... 插件 bundle、Remote 流 mux、HMR 事件流,以及启动入口清理 token 时重定向到的 /。当一个监听端口位于剥离前缀的代理之后(例如 https://host/tools/dsh/ 这样的挂载,把 /tools/dsh/... 转发为 /...),这些请求会落在源站根,那里没有任何路由应答:外壳、插件 bundle、流全部落空。同一份 bundle 必须同时服务源站根与任一挂载,而不能为此再构建一次。
挂载点无法从传输层恢复。配置 base URL 会把它变成部署输入,转发前缀推断会让请求头对路由具有权威性,代理内改写在碎片里重复同一事实。唯一已经知道挂载点的地方,就是所服务的文档自身。
决策
所服务的 HTTP 外壳拥有自己的文档 base。其 index 携带唯一的 <base href="./">,在原始 index 转换执行后拼接于起始 head 标签之后,因此它先于启动 manifest、每一行插件资源,以及任何转换插入的标记。该 base 冻结入口目录:/mount/ 与 /mount/index.html 是受支持的入口,而文档 URL 之后移动(页面内 pushState)不会移动 base。系统不推断任何前缀,因此深层直接加载与重新加载不是入口;裸挂载仍需依赖代理自身的规范化补齐尾部斜杠。
浏览器对这些路由的引用相对该文档——api/x、plugins/x——而服务端路由键保持绝对路径名:RPC channel 键 /api、webServer 注册路径,以及以 combo 路由 /plugins/??...&rev=... 为键的响应表。命名路由键的共享常量(*_PATH、*_ENDPOINT)在边界处被剥去前导斜杠(KEY.slice(1))后浏览器代码才使用,浏览器形式以 *_ROUTE 与之并列。
宿主在每个生产者里只在一处组合浏览器引用。模块启动图 row 与批次描述符携带文档相对的 combo 引用;source-map trailer 只携带 combo 查询,因为脚本的 source-map 引用相对该脚本自身目录(.../plugins/)解析,而非相对文档。响应表仍以绝对路由为键,而请求在代理剥去前缀后到达的也正是该路由。
启动入口遵循同一规则。authenticatedUrl 把进程 token 加到调用方的 URL 上并保留其 authority 与挂载;token 交换重定向到 ./,移除 query token 的同时保留请求 URL 的目录。Host/Origin 栅栏与 cookie 规则不变。
需要绝对 URL 的消费方相对 document.baseURI 解析:Gateway 构造 WebSocket URL 并选择 ws: 或 wss: 时,以及 Inspector 从启动图定位自身 bundle 时。
Desktop 是另一个文档所有者。它的窗口加载 dsh-app://app/:该来源在根目录提供同一份 dist,并把其他所有应用路径转发给它拥有的 Host,因此它的文档目录本就是根目录,无需为它注入 base 行;它改为通过传输层的 streamBaseUrl 为 Gateway 指定该 Host origin。它的资源就位于该根目录,这也是所服务 HTTP 外壳的 ./ 不适用于它的原因。
曾考虑的替代方案
把配置 base URL 当作路由 base(publicUrl)。 否决:文档已经知道自己的 URL,因此这是为页面本可读取的事实引入第二个易漂移来源,而且必须把它接入每个消费方,而不是只做一次外壳决策。对外公告的公开 URL 仍是面向运维的打印/打开输入,不是路由输入。
从转发前缀请求头推断挂载点。 否决:这会让请求头对路由具有权威性,任何设置该头的客户端都能挪动应用自有路由,代理约定也会变成服务端可见的配置。
共享 Connection.resolveUrl 或 routes base。 HTTP 载体的文档基准与 worker tunnel 的 Host 根路径映射不同。在 Connection 中统一解析所有目标,会把静态 preview 路径加到 worker 本地路由之前,或丢失所服务的挂载。各载体解析自己的目标;UI 资源消费方使用文档基准。
保留绝对 / 重定向并让代理改写 Location。 否决:浏览器已经知道请求所在目录,而配置出来的重定向目标无法同时适用于 loopback 入口与代理挂载。
后果
一份 bundle 服务源站根与任一挂载,所服务的文档是挂载点的唯一来源。代价是显式的:受支持的入口只有 dist 根与配置的 index 路径;裸挂载依赖代理的斜杠规范化;页面内深层路径重新加载会在该路径重新进入,而非冻结目录。
功能包自有的路由(open-in-app、deliverables、会话日志导出、上传、markdown 媒体)通过各自的 *_ROUTE 常量遵循同一规则;让浏览器源码持续遵守该规则的静态门禁随那些改动一并记录。
测试
frontend-static 的真实组合测试通过 Loader 提供 index,并断言唯一的 <base href="./"> 先于注入的插件资源行与转换标记。client-modules node 半侧测试断言启动图 row、批次描述符与 source-map trailer 均为文档相对,而响应表仍按路由提供它们,客户端测试 roster 与 assembled-boot fixture 携带同一相对形式。browser-auth.host.spec.ts 覆盖 ./ 重定向与保留 authority 和挂载的 authenticatedUrl;frontend-static.spec.ts 与 apps/cli/tests/web-auth.e2e.ts 分别经真实 Loader 组合与构建后的 CLI 运行该交换。
apps/web/tests/public-mount.e2e.ts 是浏览器证据:真实 Chromium 经剥离前缀的代理到达同一监听端口,挂载下的 WebSocket 双向成流,pushState 到更深路径后 document.baseURI 仍停在挂载点,而文档相对的 fetch('api/session/create') 在其下依然成功。