feat(web): serve the shell, API and plugin resources from the document directory
This commit is contained in:
1 parent
c0489984ff
commit
eeb9b03465
58 files changed
+866
-280
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
|
||||
2026-08-24-browser-token-authentication.md: cdfed1f250de6def39e386cf9fcd2e536c6c97e4
|
||||
2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f
|
||||
2026-08-24-browser-token-authentication.md: 47d6afe1bf4cd8169eb4e8ec92a30bf0fee32c8a
|
||||
2026-08-24-browser-token-authentication.zh.md: 13ecf55e41c7bb1455177bdd6a87a3a83d94ca6f
|
||||
@@ -12,9 +12,9 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
|
||||
|
||||
`dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `/`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
|
||||
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
|
||||
|
||||
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling.
|
||||
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation.
|
||||
|
||||
The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
|
||||
|
||||
|
||||
+2
-2
@@ -12,9 +12,9 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
|
||||
|
||||
`dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。
|
||||
|
||||
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `/`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
|
||||
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
|
||||
|
||||
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。
|
||||
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。
|
||||
|
||||
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md
|
||||
2026-09-14-web-document-relative-app-routes.md: fbf30253bbfcc23eb09847f93c1b1f047459d31b
|
||||
2026-09-14-web-document-relative-app-routes.zh.md: c302ec88a4205239148132513d1ff2a4385033e8
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Web app-owned routes are document-relative
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-14-web-document-relative-app-routes.zh.md)
|
||||
|
||||
## 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.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Web 应用自有路由相对文档寻址
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-14-web-document-relative-app-routes.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
浏览器对外壳自有路由的每一处引用都以源站根绝对形式书写:`/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')` 在其下依然成功。
|
||||
@@ -138,7 +138,7 @@ function start(f: ReturnType<typeof fixture>, extra: string[] = []) {
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('__DSH_BOOT__')
|
||||
const bundlePath = /<script src="(\/plugins\/[^"]+)"/u.exec(html)?.[1]?.replaceAll('&', '&')
|
||||
const bundlePath = /<script src="(plugins\/[^"]+)"/u.exec(html)?.[1]?.replaceAll('&', '&')
|
||||
if (!bundlePath) throw new Error('Missing bootstrap bundle URL')
|
||||
const bundle = await fetch(new URL(bundlePath, url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
|
||||
expect(bundle.status).toBe(200)
|
||||
|
||||
@@ -172,7 +172,10 @@ describe('dsh web authentication through the real CLI', () => {
|
||||
|
||||
const exchange = await fetch(first.launchUrl, { redirect: 'manual' })
|
||||
expect(exchange.status).toBe(303)
|
||||
expect(exchange.headers.get('location')).toBe('/')
|
||||
const location = exchange.headers.get('location')
|
||||
if (location === null) throw new Error('real CLI token exchange omitted Location')
|
||||
const redirectedUrl = new URL(location, firstUrl)
|
||||
expect(redirectedUrl.href).toBe(`${firstUrl.origin}/`)
|
||||
const setCookie = exchange.headers.get('set-cookie')
|
||||
if (setCookie === null) throw new Error('real CLI token exchange omitted Set-Cookie')
|
||||
expect(setCookie).toContain('HttpOnly')
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "DeepSeek Harness",
|
||||
"short_name": "DSH",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "fullscreen",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
|
||||
@@ -91,8 +91,9 @@ function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): s
|
||||
return resolve(dirname(packagePath), relative)
|
||||
}
|
||||
|
||||
const comboUrl = (ids: readonly string[], rev: string): string =>
|
||||
`/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
/** App-directory-relative combo references, matching the wire the Host composes. */
|
||||
const comboReference = (ids: readonly string[], rev: string): string =>
|
||||
`plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
|
||||
/** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */
|
||||
function loadAssembledPlugins(): readonly AssembledPlugin[] {
|
||||
@@ -112,7 +113,7 @@ function loadAssembledPlugins(): readonly AssembledPlugin[] {
|
||||
plugins.set(entry.name, {
|
||||
id: entry.name,
|
||||
bundlePath: resolveClientExport(packagePath, pkg),
|
||||
url: comboUrl([entry.name], 'fx'),
|
||||
url: comboReference([entry.name], 'fx'),
|
||||
rev: 'fx',
|
||||
...(declaration.inject === undefined ? {} : { inject: declaration.inject }),
|
||||
...(declaration.external === undefined ? {} : { external: declaration.external }),
|
||||
@@ -145,13 +146,13 @@ function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
|
||||
batches: [
|
||||
...(bootstrapEntries.length === 0 ? [] : [{
|
||||
phase: 'bootstrap' as const,
|
||||
url: comboUrl(bootstrapEntries, 'fx'),
|
||||
url: comboReference(bootstrapEntries, 'fx'),
|
||||
rev: 'fx',
|
||||
entries: bootstrapEntries,
|
||||
}]),
|
||||
...(applicationEntries.length === 0 ? [] : [{
|
||||
phase: 'application' as const,
|
||||
url: comboUrl(applicationEntries, 'fx'),
|
||||
url: comboReference(applicationEntries, 'fx'),
|
||||
rev: 'fx',
|
||||
entries: applicationEntries,
|
||||
}]),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Test-only HTTP plumbing for the Web browser scaffold beside it
|
||||
* (`./scaffold.ts`). The proxy owns one browser-facing mount: it preserves the
|
||||
* external Host, strips the prefix, redirects the bare mount to its slash form,
|
||||
* removes hop-by-hop headers, forwards WebSocket upgrades, and rewrites the
|
||||
* backend's `Path=/` cookies to the mount. TLS terminates at the real
|
||||
* deployment's proxy; this fixture stays plain HTTP.
|
||||
*/
|
||||
import {
|
||||
createServer as createHttpServer, request as httpRequest,
|
||||
type IncomingMessage, type OutgoingHttpHeaders, type ServerResponse,
|
||||
} from 'node:http'
|
||||
import { connect as netConnect, type AddressInfo, type Socket } from 'node:net'
|
||||
|
||||
/** Headers that describe one hop and must not reach the origin. */
|
||||
const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'te', 'trailer']
|
||||
|
||||
/** Options for {@link startPrefixProxy}. */
|
||||
export interface PrefixProxyOptions {
|
||||
/** Browser-facing mount; must start with `/` and end with `/`. */
|
||||
prefix: string
|
||||
}
|
||||
|
||||
/** One running prefix proxy; its owner disposes it with {@link PrefixProxy.close}. */
|
||||
export interface PrefixProxy {
|
||||
/** Loopback port assigned by the operating system. */
|
||||
readonly port: number
|
||||
/** Point the proxy at the backend listener; requests before this answer `502`. */
|
||||
setTarget(port: number): void
|
||||
/** Stop accepting, destroy live sockets, and resolve once every listener and socket closed. */
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the proxy on an operating-system-assigned loopback port.
|
||||
* @param options - browser-facing mount prefix.
|
||||
* @returns the running proxy; the caller owns registration and disposal.
|
||||
*/
|
||||
export async function startPrefixProxy(options: PrefixProxyOptions): Promise<PrefixProxy> {
|
||||
const { prefix } = options
|
||||
const server = createHttpServer()
|
||||
let targetPort: number | undefined
|
||||
let closing: Promise<void> | undefined
|
||||
const sockets = new Set<Socket>()
|
||||
const track = (socket: Socket): void => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => { sockets.delete(socket) })
|
||||
if (closing !== undefined) socket.destroy()
|
||||
}
|
||||
const mountPath = (raw: string): { path: string } | { redirect: string } | undefined => {
|
||||
const url = new URL(raw, 'http://dsh.invalid')
|
||||
if (url.pathname === prefix.slice(0, -1)) return { redirect: `${prefix}${url.search}` }
|
||||
if (!url.pathname.startsWith(prefix)) return undefined
|
||||
return { path: `/${url.pathname.slice(prefix.length)}${url.search}` }
|
||||
}
|
||||
const forward = (headers: IncomingMessage['headers']): OutgoingHttpHeaders => {
|
||||
const copy = { ...headers }
|
||||
for (const name of HOP_BY_HOP) Reflect.deleteProperty(copy, name)
|
||||
return copy
|
||||
}
|
||||
const rewriteCookies = (headers: IncomingMessage['headers']): OutgoingHttpHeaders => {
|
||||
const copy = forward(headers)
|
||||
const setCookie = copy['set-cookie']
|
||||
if (setCookie === undefined) return copy
|
||||
const values = Array.isArray(setCookie) ? setCookie : [setCookie]
|
||||
copy['set-cookie'] = values.map(value => value.replace(/(^|;\s*)Path=\/(?=;|$)/iu, `$1Path=${prefix}`))
|
||||
return copy
|
||||
}
|
||||
server.on('request', (requestMessage: IncomingMessage, response: ServerResponse) => {
|
||||
if (closing !== undefined) { response.destroy(); return }
|
||||
const routed = mountPath(requestMessage.url ?? '/')
|
||||
if (routed === undefined) {
|
||||
response.writeHead(404)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if ('redirect' in routed) {
|
||||
response.writeHead(308, { location: routed.redirect, 'cache-control': 'no-store' })
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (targetPort === undefined) {
|
||||
response.writeHead(502)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
const upstream = httpRequest({
|
||||
host: '127.0.0.1', port: targetPort, method: requestMessage.method, path: routed.path,
|
||||
headers: forward(requestMessage.headers), agent: false,
|
||||
}, (result) => {
|
||||
response.writeHead(result.statusCode as number, rewriteCookies(result.headers))
|
||||
result.pipe(response)
|
||||
})
|
||||
upstream.on('socket', track)
|
||||
upstream.once('error', (error) => { response.destroy(error) })
|
||||
response.once('close', () => { upstream.destroy() })
|
||||
requestMessage.pipe(upstream)
|
||||
})
|
||||
// An upgrade speaks its own hop-by-hop framing, so it cannot ride the
|
||||
// request handler: replay the request line and raw headers at the backend,
|
||||
// then pipe both directions raw until either side closes.
|
||||
server.on('upgrade', (requestMessage, socket, head) => {
|
||||
const routed = mountPath(requestMessage.url ?? '/')
|
||||
if (closing !== undefined || targetPort === undefined || routed === undefined || 'redirect' in routed) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
const upstream = netConnect(targetPort, '127.0.0.1')
|
||||
track(upstream)
|
||||
upstream.once('connect', () => {
|
||||
upstream.write(`${requestMessage.method} ${routed.path} HTTP/1.1\r\n`)
|
||||
for (let index = 0; index < requestMessage.rawHeaders.length; index += 2) {
|
||||
upstream.write(`${requestMessage.rawHeaders[index]}: ${requestMessage.rawHeaders[index + 1]}\r\n`)
|
||||
}
|
||||
upstream.write('\r\n')
|
||||
if (head.length > 0) upstream.write(head)
|
||||
socket.pipe(upstream).pipe(socket)
|
||||
})
|
||||
upstream.once('error', () => { socket.destroy() })
|
||||
socket.once('error', () => { upstream.destroy() })
|
||||
upstream.once('close', () => { socket.destroy() })
|
||||
socket.once('close', () => { upstream.destroy() })
|
||||
})
|
||||
server.on('connection', track)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const { port } = server.address() as AddressInfo
|
||||
return {
|
||||
port,
|
||||
setTarget(port: number) { targetPort = port },
|
||||
close() {
|
||||
closing ??= (async () => {
|
||||
const stopped = new Promise<void>((resolve) => { server.close(() => { resolve() }) })
|
||||
const drained = [...sockets].map(socket => new Promise<void>((resolve) => {
|
||||
socket.once('close', () => { resolve() })
|
||||
socket.destroy()
|
||||
}))
|
||||
await Promise.all([stopped, ...drained])
|
||||
})()
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Real Chromium exercises plugin bundles, RPC, and bidirectional Gateway
|
||||
// streams through a prefix-stripping HTTP proxy.
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, WELCOME_NOTICE_COPY,
|
||||
type WebConsoleTripwire, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const MOUNT = '/tools/dsh/'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: public mount through a prefix-stripping proxy', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: WebConsoleTripwire
|
||||
const requestUrls: string[] = []
|
||||
const socketUrls: string[] = []
|
||||
const muxFrames: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
publicMount: { prefix: MOUNT },
|
||||
welcomeNoticePending: true,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
locale: ZH_BROWSER_LOCALE,
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
page.on('request', (request) => { requestUrls.push(request.url()) })
|
||||
page.on('websocket', (socket) => {
|
||||
socketUrls.push(socket.url())
|
||||
if (new URL(socket.url()).pathname !== `${MOUNT}api/remote.mux`) return
|
||||
socket.on('framesent', (frame) => { muxFrames.push(`sent ${String(frame.payload)}`) })
|
||||
socket.on('framereceived', (frame) => { muxFrames.push(`received ${String(frame.payload)}`) })
|
||||
})
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('#root', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
/** Same-origin requests outside the mount: the proxy strips no prefix from them, so nothing answers. */
|
||||
const offMountRequests = (): string[] => requestUrls.filter(url =>
|
||||
new URL(url).origin === new URL(scaffold.baseUrl).origin && !url.startsWith(scaffold.baseUrl))
|
||||
|
||||
it('loads the shell, its plugin bundles, and the Gateway WebSocket under the mount', async () => {
|
||||
const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(page.url().startsWith(scaffold.baseUrl)).toBe(true)
|
||||
|
||||
// Every resource the page requested — bundles, manifest, icon, and whatever
|
||||
// a later change adds — stayed under the mount; the bundle check keeps the
|
||||
// empty off-mount list from passing vacuously.
|
||||
expect(requestUrls.some(url => new URL(url).pathname.startsWith(`${MOUNT}plugins/`))).toBe(true)
|
||||
expect(offMountRequests()).toEqual([])
|
||||
|
||||
// The browser resolves manifest members against the manifest URL and the
|
||||
// start URL's origin, not the document base, so read its own resolution.
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
const { manifest } = await cdp.send('Page.getAppManifest') as {
|
||||
manifest?: { id?: string; scope?: string; startUrl?: string }
|
||||
}
|
||||
expect([manifest?.id, manifest?.scope, manifest?.startUrl]).toEqual([scaffold.baseUrl, scaffold.baseUrl, scaffold.baseUrl])
|
||||
|
||||
// The mux is a WebSocket upgrade: without upgrade forwarding at the proxy,
|
||||
// the client's stream opens never reach the Host.
|
||||
expect(socketUrls.map(url => new URL(url).pathname)).toContain(`${MOUNT}api/remote.mux`)
|
||||
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
// The forward must be a live two-way pipe, not just a handshake: the page's
|
||||
// opens reach the Host and its items come back over the same mounted socket.
|
||||
await expect.poll(
|
||||
() => muxFrames.some(frame => frame.startsWith('sent ') && frame.includes('"type":"open"')),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(true)
|
||||
await expect.poll(
|
||||
() => muxFrames.some(frame => frame.startsWith('received ') && frame.includes('"type":"item"')),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(true)
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps relative fetches under the mount after in-page navigation', async () => {
|
||||
const observed = await page.evaluate(async (mount) => {
|
||||
const initialUrl = location.href
|
||||
history.pushState(null, '', `${mount}session/42`)
|
||||
try {
|
||||
const response = await fetch('api/session/create', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'public-mount-relative-session',
|
||||
method: 'session/create',
|
||||
payload: { args: { request: {} } },
|
||||
}),
|
||||
})
|
||||
return {
|
||||
baseURI: document.baseURI,
|
||||
pathname: location.pathname,
|
||||
status: response.status,
|
||||
body: await response.json() as { result?: { ok?: boolean } },
|
||||
}
|
||||
} finally {
|
||||
history.replaceState(null, '', initialUrl)
|
||||
}
|
||||
}, MOUNT)
|
||||
expect(observed.baseURI).toBe(scaffold.baseUrl)
|
||||
expect(observed.pathname).toBe(`${MOUNT}session/42`)
|
||||
expect(observed.status).toBe(200)
|
||||
expect(observed.body.result?.ok).toBe(true)
|
||||
expect(offMountRequests()).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -10,15 +10,17 @@ it('ships install metadata with the built web application', async () => {
|
||||
expect(index).toContain('<link rel="manifest" href="./manifest.webmanifest" />')
|
||||
|
||||
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
|
||||
// No `id`: a browser resolves an explicit `id` against the start URL's origin,
|
||||
// so only an absent `id`, which defaults to the resolved `start_url`, gives
|
||||
// each mount its own identity. `public-mount.e2e.ts` reads the resolved form.
|
||||
expect(manifest).toEqual({
|
||||
id: '/',
|
||||
name: 'DeepSeek Harness',
|
||||
short_name: 'DSH',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
start_url: './',
|
||||
scope: './',
|
||||
display: 'fullscreen',
|
||||
icons: [{
|
||||
src: '/favicon.svg',
|
||||
src: 'favicon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
|
||||
@@ -83,6 +83,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { startPrefixProxy, type PrefixProxy } from './prefix-proxy.ts'
|
||||
import { REPO_ROOT, requireBuilt, requireDist } from './support.ts'
|
||||
|
||||
type AppBoot = typeof import('@deepseek-ai/dsh-app-boot')
|
||||
@@ -275,7 +276,7 @@ function replayProviders(contextWindow: number | undefined, messages: boolean):
|
||||
export interface WebScaffold {
|
||||
/** The active snapshot mode this scaffold booted under. */
|
||||
mode: WebSnapshotMode
|
||||
/** Browser-facing origin for the bound test server. */
|
||||
/** Browser-facing origin for the bound test server (the mount root under `publicMount`). */
|
||||
baseUrl: string
|
||||
/** Process-token URL that establishes this scaffold's browser session. */
|
||||
authenticatedUrl: string
|
||||
@@ -431,6 +432,18 @@ export interface LaunchOptions {
|
||||
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
|
||||
*/
|
||||
remoteAuthority?: string
|
||||
/**
|
||||
* Serve the same listener through the private plain-HTTP prefix-stripping
|
||||
* proxy in `./prefix-proxy.ts`, which owns the mount, upgrade, and cookie
|
||||
* behavior. The proxy authority joins `trustedHosts` because the direct
|
||||
* composition grants no trust. The listen socket is unaffected.
|
||||
*/
|
||||
publicMount?: {
|
||||
/** Canonical hostname Chromium maps to loopback (default `public.localhost`). */
|
||||
host?: string
|
||||
/** Canonical mount prefix (default `tools/dsh/`, normalized to lead and end in `/`). */
|
||||
prefix?: string
|
||||
}
|
||||
/** Reuse an existing harness home so a second Host can verify user settings across origins. */
|
||||
harnessHome?: string
|
||||
}
|
||||
@@ -463,6 +476,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
? undefined
|
||||
: await Promise.all(options.replayChildFixtures.map(path => selectedSessionFixture(path)))
|
||||
const compareReplaySession = options.compareReplaySession ?? await ownsReplayFixture(replayFixture)
|
||||
const publicMount = options.publicMount
|
||||
const publicHost = publicMount === undefined ? undefined : publicMount.host ?? 'public.localhost'
|
||||
const publicPrefix = publicMount === undefined
|
||||
? undefined
|
||||
: `/${(publicMount.prefix ?? 'tools/dsh/').replace(/^\/+|\/+$/gu, '')}/`
|
||||
const browserHost = options.remoteAuthority ?? '127.0.0.1'
|
||||
if (mode === 'record') {
|
||||
// Both owning vitest configs (web unconditionally, snapshot in record
|
||||
@@ -630,9 +648,17 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// Preserve the composed surface-context choice because a patch replaces
|
||||
// the row's complete config.
|
||||
{ id: 'web-runtime', config: { openBrowser: false, printUrl: false, surfaceContext } },
|
||||
...options.remoteAuthority === undefined
|
||||
...publicHost === undefined && options.remoteAuthority === undefined
|
||||
? []
|
||||
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
|
||||
: [{
|
||||
id: 'connection',
|
||||
config: {
|
||||
trustedHosts: [
|
||||
...publicHost === undefined ? [] : [publicHost],
|
||||
...options.remoteAuthority === undefined ? [] : [options.remoteAuthority],
|
||||
],
|
||||
},
|
||||
}],
|
||||
{ id: 'settings', config: { dshHome: harnessHome } },
|
||||
{ id: 'credentials', config: { dshHome: harnessHome } },
|
||||
// The shipped directory-picker row is the -auto chooser, which resolves
|
||||
@@ -685,8 +711,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
let baseUrl = ''
|
||||
let authenticatedUrl = ''
|
||||
let cookieHeader = ''
|
||||
let publicProxy: PrefixProxy | undefined
|
||||
let replayHandle: ReplayHandle | undefined
|
||||
try {
|
||||
// The proxy takes its browser-facing port before the boot so the mount URL
|
||||
// is final by the time any row reads it; its target follows the listener.
|
||||
if (publicHost !== undefined && publicPrefix !== undefined) {
|
||||
publicProxy = await startPrefixProxy({ prefix: publicPrefix })
|
||||
}
|
||||
process.chdir(workspaceCwd)
|
||||
const profileDir = join(harnessHome, 'profiles', 'scaffold')
|
||||
const extraLayers: Profile['layers'] = await Promise.all((options.extraInstallAnchors ?? []).map(async (anchor) => {
|
||||
@@ -855,11 +887,22 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
|
||||
), 'web e2e scaffold: route-only adapter')
|
||||
}
|
||||
baseUrl = `http://${browserHost}:${String(port)}`
|
||||
if (publicProxy === undefined || publicHost === undefined || publicPrefix === undefined) {
|
||||
baseUrl = `http://${browserHost}:${String(port)}`
|
||||
} else {
|
||||
// The browser talks to the proxy's mount; Node-side requests emulate the
|
||||
// browser by keeping the proxy's port while connecting to loopback.
|
||||
publicProxy.setTarget(port)
|
||||
baseUrl = `http://${publicHost}:${String(publicProxy.port)}${publicPrefix}`
|
||||
}
|
||||
authenticatedUrl = ctx.connection.authenticatedUrl(baseUrl)
|
||||
const login = await fetch(authenticatedUrl, { redirect: 'manual' })
|
||||
// Chromium resolves *.localhost itself; Node may not, so a mounted scaffold
|
||||
// posts the exchange to loopback, the authority the Host fence always trusts.
|
||||
const loginUrl = new URL(authenticatedUrl)
|
||||
if (publicPrefix !== undefined) loginUrl.hostname = '127.0.0.1'
|
||||
const login = await fetch(loginUrl, { redirect: 'manual' })
|
||||
const setCookie = login.headers.get('set-cookie')
|
||||
if (login.status !== 303 || login.headers.get('location') !== '/' || setCookie === null) {
|
||||
if (login.status !== 303 || login.headers.get('location') !== './' || setCookie === null) {
|
||||
throw new Error('web e2e scaffold: browser token exchange did not return its session cookie')
|
||||
}
|
||||
cookieHeader = setCookie.split(';', 1)[0] ?? ''
|
||||
@@ -869,6 +912,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
} catch (error) {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
|
||||
if (publicProxy !== undefined) {
|
||||
await publicProxy.close().catch((closeError: unknown) => cleanupFailures.push(closeError))
|
||||
}
|
||||
restoreCredentialEnvironment()
|
||||
restoreSkillRootEnvironment()
|
||||
if (cleanupFailures.length > 0) {
|
||||
@@ -890,7 +936,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
hostFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('cookie', cookieHeader)
|
||||
return fetch(new URL(path, baseUrl), { ...init, headers })
|
||||
// Same loopback routing as the token exchange above.
|
||||
const routed = publicPrefix === undefined ? path : `${publicPrefix}${path.replace(/^\//u, '')}`
|
||||
const url = new URL(routed, baseUrl)
|
||||
if (publicPrefix !== undefined) url.hostname = '127.0.0.1'
|
||||
return fetch(url, { ...init, headers })
|
||||
},
|
||||
// Barrier stack: the in-process turn/end identifies the session, its
|
||||
// explicit flush makes the transcript durable, and the caller's browser
|
||||
@@ -942,6 +992,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
try {
|
||||
stopObservingSessions()
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
if (publicProxy !== undefined) {
|
||||
try {
|
||||
await publicProxy.close()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
restoreCredentialEnvironment()
|
||||
restoreSkillRootEnvironment()
|
||||
@@ -1643,10 +1700,20 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P
|
||||
/**
|
||||
* Console tripwires: reconnect/gap-repair self-healing or a pageerror must
|
||||
* fail the scenario, not mask a dead wire behind eventual consistency.
|
||||
*/
|
||||
export interface WebConsoleTripwire {
|
||||
/** Console warnings matching reconnect/gap-repair/discontinuity copy. */
|
||||
warnings: string[]
|
||||
/** Uncaught page errors. */
|
||||
pageErrors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the {@link WebConsoleTripwire} of one page.
|
||||
* @param page - the page under test.
|
||||
* @returns live warning/pageerror collectors to assert empty at scenario end.
|
||||
*/
|
||||
export function watchConsole(page: Page): { warnings: string[]; pageErrors: string[] } {
|
||||
export function watchConsole(page: Page): WebConsoleTripwire {
|
||||
const warnings: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
@@ -1664,7 +1731,7 @@ export function watchConsole(page: Page): { warnings: string[]; pageErrors: stri
|
||||
* @param warningStart - warning count captured immediately before reloading.
|
||||
*/
|
||||
export function acknowledgeReloadConnectionLoss(
|
||||
tripwire: ReturnType<typeof watchConsole>,
|
||||
tripwire: WebConsoleTripwire,
|
||||
warningStart: number,
|
||||
): void {
|
||||
const reloadWarnings = tripwire.warnings.splice(warningStart)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"tests/default-product-isolation.e2e.ts",
|
||||
"tests/diff-context.e2e.ts",
|
||||
"tests/scaffold.ts",
|
||||
"tests/prefix-proxy.ts",
|
||||
"tests/auto-review-fixture.ts",
|
||||
"tests/scaffold-generation.spec.ts",
|
||||
"tests/scaffold-hermetic.e2e.ts",
|
||||
@@ -63,6 +64,7 @@
|
||||
"tests/onboarding-deepseek-config.e2e.ts",
|
||||
"tests/onboarding-usable-provider.e2e.ts",
|
||||
"tests/remote-welcome.e2e.ts",
|
||||
"tests/public-mount.e2e.ts",
|
||||
"tests/workspace-new-session-folding.e2e.ts",
|
||||
"tests/workspace-management.e2e.ts",
|
||||
"tests/workspace-recency.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md
|
||||
client-modules.md: 89f3e675628bca71f001170b5efabb241134dfb0
|
||||
client-modules.zh.md: 1a41b4bd061ebf83f80c6f7c1633932da13b5942
|
||||
client-modules.md: 5a62340ff3baa3cd2ed63d453fe2a055b2753ac0
|
||||
client-modules.zh.md: b5a145460f30f3de6be6434315b94c01e04f6378
|
||||
@@ -22,7 +22,10 @@ The graph is the wire single source between the Node and browser halves. The hos
|
||||
interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Revisioned single-resource combo endpoint used by HMR. */
|
||||
/**
|
||||
* Revisioned single-resource combo reference used by HMR. It is relative to
|
||||
* the document, so the browser resolves it under whatever mount served the page.
|
||||
*/
|
||||
url: string
|
||||
/** Opaque plugin-artifact revision used for HMR cache busting. */
|
||||
rev: string
|
||||
@@ -45,7 +48,7 @@ type WebBootBatchPhase = 'bootstrap' | 'application'
|
||||
interface WebBootBatch {
|
||||
/** Parser-blocking bootstrap or preloaded application scheduling. */
|
||||
phase: WebBootBatchPhase
|
||||
/** Revisioned combo script endpoint. */
|
||||
/** Content-addressed combo script reference, document-relative like {@link WebBootEntry.url}. */
|
||||
url: string
|
||||
/** Revision derived from the ordered entry revisions. */
|
||||
rev: string
|
||||
@@ -82,7 +85,7 @@ Package metadata — including the negative "not a client package" verdict — i
|
||||
|
||||
## The bundle route and index injection
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with an absolute `sourceMappingURL` whose resource suffixes are `.js.map`. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
|
||||
## The service
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client
|
||||
interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Revisioned single-resource combo endpoint used by HMR. */
|
||||
/**
|
||||
* Revisioned single-resource combo reference used by HMR. It is relative to
|
||||
* the document, so the browser resolves it under whatever mount served the page.
|
||||
*/
|
||||
url: string
|
||||
/** Opaque plugin-artifact revision used for HMR cache busting. */
|
||||
rev: string
|
||||
@@ -45,7 +48,7 @@ type WebBootBatchPhase = 'bootstrap' | 'application'
|
||||
interface WebBootBatch {
|
||||
/** Parser-blocking bootstrap or preloaded application scheduling. */
|
||||
phase: WebBootBatchPhase
|
||||
/** Revisioned combo script endpoint. */
|
||||
/** Content-addressed combo script reference, document-relative like {@link WebBootEntry.url}. */
|
||||
url: string
|
||||
/** Revision derived from the ordered entry revisions. */
|
||||
rev: string
|
||||
@@ -82,7 +85,7 @@ interface WebBootGraph {
|
||||
|
||||
## bundle 路由与 index 注入
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以绝对 `sourceMappingURL` 结尾,其中每个资源后缀改为 `.js.map`。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
|
||||
/** Physical Remote stream socket failure that may be retried by a domain transport. */
|
||||
export class RemoteStreamCarrierError extends Error {
|
||||
/**
|
||||
@@ -30,7 +28,11 @@ interface SocketWaiter {
|
||||
reject(error: unknown): void
|
||||
}
|
||||
|
||||
/** Keep one physical WebSocket and share it among independently cancellable Remote streams. */
|
||||
/**
|
||||
* Keep one physical WebSocket and share it among independently cancellable
|
||||
* Remote streams. A carrier that supplies an in-process stream opener never
|
||||
* starts one.
|
||||
*/
|
||||
export class RemoteStreamMuxClient {
|
||||
private socket: WebSocket | undefined
|
||||
private cancelCandidate: ((error: Error) => void) | undefined
|
||||
@@ -302,10 +304,11 @@ class StreamInbox {
|
||||
}
|
||||
|
||||
function remoteStreamUrl(): string {
|
||||
const location = (globalThis as { location?: { origin?: string } }).location
|
||||
const transport = (globalThis as { __DSH_TRANSPORT__?: { streamBaseUrl?: string } }).__DSH_TRANSPORT__
|
||||
const base = transport?.streamBaseUrl ?? (location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE)
|
||||
const url = new URL(REMOTE_STREAM_MUX_PATH, base)
|
||||
// The mux route is registered absolute; a page resolves its document-relative
|
||||
// form against its own document base. A shell-owned Host on another origin
|
||||
// supplies that base through the transport.
|
||||
const globals = globalThis as { __DSH_TRANSPORT__?: { streamBaseUrl?: string } }
|
||||
const url = new URL(REMOTE_STREAM_MUX_PATH.slice(1), globals.__DSH_TRANSPORT__?.streamBaseUrl ?? document.baseURI)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return url.href
|
||||
}
|
||||
@@ -2339,11 +2339,11 @@ describe('Client Typert API', () => {
|
||||
|
||||
it('multiplexes Remote streams without using the Connection RPC caller', async () => {
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
||||
const documentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document')
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: { origin: 'https://harness.example' },
|
||||
value: { baseURI: 'https://harness.example/' },
|
||||
})
|
||||
FakeWebSocket.sockets.length = 0
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
@@ -2412,8 +2412,8 @@ describe('Client Typert API', () => {
|
||||
FakeWebSocket.dispatchClose = true
|
||||
if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
|
||||
else globalThis.WebSocket = originalWebSocket
|
||||
if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location')
|
||||
else Object.defineProperty(globalThis, 'location', locationDescriptor)
|
||||
if (documentDescriptor === undefined) Reflect.deleteProperty(globalThis, 'document')
|
||||
else Object.defineProperty(globalThis, 'document', documentDescriptor)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2568,8 +2568,8 @@ describe('Remote stream client carrier lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shares an in-flight connection and uses the internal ws URL without a browser origin', async () => {
|
||||
await withFakeWebSocket(undefined, async () => {
|
||||
it('shares an in-flight connection and resolves the mux route against the document base', async () => {
|
||||
await withFakeWebSocket('https://harness.example/mounted/app/', async () => {
|
||||
FakeWebSocket.autoOpen = false
|
||||
const client = new RemoteStreamMuxClient()
|
||||
client.start()
|
||||
@@ -2581,7 +2581,7 @@ describe('Remote stream client carrier lifecycle', () => {
|
||||
const secondPending = second.next()
|
||||
expect(FakeWebSocket.sockets).toHaveLength(1)
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
expect(socket.url).toBe('ws://dsh.internal/api/remote.mux')
|
||||
expect(socket.url).toBe('wss://harness.example/mounted/app/api/remote.mux')
|
||||
|
||||
socket.open()
|
||||
await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
|
||||
@@ -2595,7 +2595,7 @@ describe('Remote stream client carrier lifecycle', () => {
|
||||
})
|
||||
|
||||
it('fails waiters with one socket attempt and lets the owner start the next attempt', async () => {
|
||||
await withFakeWebSocket('null', async () => {
|
||||
await withFakeWebSocket('http://harness.example/', async () => {
|
||||
FakeWebSocket.autoOpen = false
|
||||
const closedClient = new RemoteStreamMuxClient()
|
||||
closedClient.start()
|
||||
@@ -2630,7 +2630,7 @@ describe('Remote stream client carrier lifecycle', () => {
|
||||
abort.abort('cancelled while connecting')
|
||||
await expect(aborted).rejects.toBe('cancelled while connecting')
|
||||
await abortedClient.close()
|
||||
expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux')
|
||||
expect(FakeWebSocket.sockets[3]?.url).toBe('ws://harness.example/api/remote.mux')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2708,14 +2708,18 @@ describe('Remote stream client carrier lifecycle', () => {
|
||||
})
|
||||
|
||||
async function withFakeWebSocket(
|
||||
origin: string | undefined,
|
||||
baseURI: string,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
const documentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document')
|
||||
const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
if (origin === undefined) Reflect.deleteProperty(globalThis, 'location')
|
||||
else Object.defineProperty(globalThis, 'location', { configurable: true, value: { origin } })
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: { baseURI } })
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: { origin: new URL(baseURI).origin },
|
||||
})
|
||||
FakeWebSocket.sockets.length = 0
|
||||
FakeWebSocket.autoOpen = true
|
||||
FakeWebSocket.dispatchClose = true
|
||||
@@ -2727,6 +2731,8 @@ async function withFakeWebSocket(
|
||||
FakeWebSocket.dispatchClose = true
|
||||
if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
|
||||
else globalThis.WebSocket = originalWebSocket
|
||||
if (documentDescriptor === undefined) Reflect.deleteProperty(globalThis, 'document')
|
||||
else Object.defineProperty(globalThis, 'document', documentDescriptor)
|
||||
if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location')
|
||||
else Object.defineProperty(globalThis, 'location', locationDescriptor)
|
||||
}
|
||||
|
||||
@@ -130,81 +130,91 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
|
||||
const origin = 'http://127.0.0.1:' + String(address.port)
|
||||
const login = await fetch(host.connection.authenticatedUrl(origin), { redirect: 'manual' })
|
||||
const setCookie = login.headers.get('set-cookie')
|
||||
if (login.status !== 303 || setCookie === null) throw new Error('browser token exchange failed')
|
||||
const cookie = setCookie.split(';', 1)[0]
|
||||
const hostFetch = globalThis.fetch
|
||||
globalThis.fetch = (input, init = {}) => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('cookie', cookie)
|
||||
return hostFetch(input, { ...init, headers })
|
||||
}
|
||||
// The browser emulation below installs page globals the Client halves
|
||||
// read; the finally restores this process's own values.
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
|
||||
const previousFetch = globalThis.fetch
|
||||
try {
|
||||
globalThis.document = { baseURI: origin + '/' }
|
||||
const login = await fetch(host.connection.authenticatedUrl(origin), { redirect: 'manual' })
|
||||
const setCookie = login.headers.get('set-cookie')
|
||||
if (login.status !== 303 || setCookie === null) throw new Error('browser token exchange failed')
|
||||
const cookie = setCookie.split(';', 1)[0]
|
||||
globalThis.fetch = (input, init = {}) => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('cookie', cookie)
|
||||
return previousFetch(new URL(input, document.baseURI), { ...init, headers })
|
||||
}
|
||||
|
||||
const handoffs = new Map()
|
||||
globalThis.window = {
|
||||
__ModuleLoader__: {
|
||||
load(handoff) { handoffs.set(handoff.id, handoff) },
|
||||
},
|
||||
}
|
||||
globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
|
||||
await import(urls.registryClient)
|
||||
await import(urls.connectionClient)
|
||||
await import(urls.apiGatewayClient)
|
||||
await import(urls.remotesClient)
|
||||
const handoffs = new Map()
|
||||
globalThis.window = {
|
||||
__ModuleLoader__: {
|
||||
load(handoff) { handoffs.set(handoff.id, handoff) },
|
||||
},
|
||||
}
|
||||
globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
|
||||
await import(urls.registryClient)
|
||||
await import(urls.connectionClient)
|
||||
await import(urls.apiGatewayClient)
|
||||
await import(urls.remotesClient)
|
||||
|
||||
const instantiate = id => {
|
||||
const handoff = handoffs.get(id)
|
||||
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
|
||||
return handoff.factory(specifier => {
|
||||
if (specifier === '@deepseek-ai/cordis') return cordis
|
||||
if (specifier === 'zod') return zod
|
||||
throw new Error('unexpected Client external ' + specifier)
|
||||
const instantiate = id => {
|
||||
const handoff = handoffs.get(id)
|
||||
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
|
||||
return handoff.factory(specifier => {
|
||||
if (specifier === '@deepseek-ai/cordis') return cordis
|
||||
if (specifier === 'zod') return zod
|
||||
throw new Error('unexpected Client external ' + specifier)
|
||||
})
|
||||
}
|
||||
const client = new Context()
|
||||
for (const id of [
|
||||
'@deepseek-ai/dsh-typert-registry',
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-api-gateway',
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
]) {
|
||||
const plugin = instantiate(id)
|
||||
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
|
||||
}
|
||||
client.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => candidate.builtAgentId,
|
||||
})
|
||||
}
|
||||
const client = new Context()
|
||||
for (const id of [
|
||||
'@deepseek-ai/dsh-typert-registry',
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-api-gateway',
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
]) {
|
||||
const plugin = instantiate(id)
|
||||
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
|
||||
}
|
||||
client.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => candidate.builtAgentId,
|
||||
})
|
||||
|
||||
const invalidResult = await client.remote.goals.create(rootAgent.id, { objective: 1 })
|
||||
// Every generated method resolves to the RemoteResult envelope; the
|
||||
// business values below are what the assertions pin.
|
||||
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
|
||||
const rootEdit = await client.remote.goals.edit(
|
||||
rootAgent.id,
|
||||
rootResult.value.ref,
|
||||
{ objective: 'edited root goal' },
|
||||
)
|
||||
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
|
||||
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
|
||||
const result = {
|
||||
invalidResult,
|
||||
rootResult: rootResult.value,
|
||||
rootEdit: rootEdit.value,
|
||||
scopedResult: scopedResult.value,
|
||||
rootGoal: host.goals.get(rootAgent)?.objective,
|
||||
scopedGoal: host.goals.get(scopedAgent)?.objective,
|
||||
rootEvents: rootAgent.session.snapshotEvents().length,
|
||||
scopedEvents: scopedAgent.session.snapshotEvents().length,
|
||||
}
|
||||
const invalidResult = await client.remote.goals.create(rootAgent.id, { objective: 1 })
|
||||
// Every generated method resolves to the RemoteResult envelope; the
|
||||
// business values below are what the assertions pin.
|
||||
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
|
||||
const rootEdit = await client.remote.goals.edit(
|
||||
rootAgent.id,
|
||||
rootResult.value.ref,
|
||||
{ objective: 'edited root goal' },
|
||||
)
|
||||
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
|
||||
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
|
||||
const result = {
|
||||
invalidResult,
|
||||
rootResult: rootResult.value,
|
||||
rootEdit: rootEdit.value,
|
||||
scopedResult: scopedResult.value,
|
||||
rootGoal: host.goals.get(rootAgent)?.objective,
|
||||
scopedGoal: host.goals.get(scopedAgent)?.objective,
|
||||
rootEvents: rootAgent.session.snapshotEvents().length,
|
||||
scopedEvents: scopedAgent.session.snapshotEvents().length,
|
||||
}
|
||||
|
||||
await client.fiber.dispose()
|
||||
await new Promise((resolveClose, rejectClose) => server.close(error => {
|
||||
if (error === undefined) resolveClose()
|
||||
else rejectClose(error)
|
||||
}))
|
||||
await host.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
await client.fiber.dispose()
|
||||
await new Promise((resolveClose, rejectClose) => server.close(error => {
|
||||
if (error === undefined) resolveClose()
|
||||
else rejectClose(error)
|
||||
}))
|
||||
await host.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
} finally {
|
||||
if (previousDocument === undefined) delete globalThis.document
|
||||
else Object.defineProperty(globalThis, 'document', previousDocument)
|
||||
globalThis.fetch = previousFetch
|
||||
}
|
||||
`
|
||||
|
||||
const result = await runPlainNode(script)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
|
||||
README.md: d74489dd294bbf0f8c09df02ffc42984517267cd
|
||||
README.zh.md: 9d80190bcf332e1271f1f00bceee34c72416cce4
|
||||
README.md: 00f523098ada6646a2cf22ce57ca5fef6c6d9afb
|
||||
README.zh.md: 2a3ea0d7d480ab2a29aa1d4adcb01836a9b70007
|
||||
@@ -34,7 +34,7 @@ dsh --profile web
|
||||
dsh --profile web --no-open --port 8080
|
||||
```
|
||||
|
||||
After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the clean root page. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself.
|
||||
After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the same directory without the token. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself.
|
||||
|
||||
**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#choose-a-protocol) defaults to Messages; set `protocol: chat-completions` in Cordis YAML to select Chat Completions. Web has no protocol selector.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ dsh --profile web
|
||||
dsh --profile web --no-open --port 8080
|
||||
```
|
||||
|
||||
启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的根页面。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。
|
||||
启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的同一目录。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。
|
||||
|
||||
**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#choose-a-protocol)默认使用 Messages;在 Cordis YAML 中设置 `protocol: chat-completions` 可选择 Chat Completions。Web 不提供协议选择器。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 19dc12b4946353634a0f71a566564454cf1f8de8
|
||||
README.zh.md: b02631005fd50a82733545c5a537728be47d3d80
|
||||
README.md: c881b39af2e1f466a84ab463e758bb5e946d25b1
|
||||
README.zh.md: ff1e9923bebc4d131d99df51c21538015f0e715e
|
||||
@@ -34,7 +34,7 @@ The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/re
|
||||
<a id="browser-authentication-and-request-trust"></a>
|
||||
## Browser authentication and request trust
|
||||
|
||||
Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token.
|
||||
Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens its application URL with `?token=...`, preserving the caller's authority and mount; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `./`, which drops the token and keeps the request's directory. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token.
|
||||
|
||||
The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ kind: "package-reference"
|
||||
<a id="browser-authentication-and-request-trust"></a>
|
||||
## 浏览器认证与请求信任
|
||||
|
||||
每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
|
||||
每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的应用 URL,保留调用方的 authority 与挂载;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `./`,移除令牌并保留请求目录。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
|
||||
|
||||
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。
|
||||
|
||||
|
||||
@@ -216,23 +216,21 @@ export class BrowserAuth {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add this process's launch token to the ordinary application root URL.
|
||||
* @param baseUrl - canonical browser origin without credentials.
|
||||
* @returns root URL carrying the process token as its sole authentication input.
|
||||
* Add this process's launch token to the caller's application URL.
|
||||
* @param baseUrl - clean browser URL whose authority and mount are preserved.
|
||||
* @returns the same URL carrying the process token as its sole authentication input.
|
||||
*/
|
||||
authenticatedUrl(baseUrl: string): string {
|
||||
const url = new URL(baseUrl)
|
||||
url.pathname = '/'
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
url.searchParams.set(TOKEN_QUERY, this.launchToken)
|
||||
return url.href
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an index request. A valid root query token mints the cookie
|
||||
* and redirects to clean `/`; a valid cookie lets the caller serve the
|
||||
* index; every other request receives the same minimal 401 response.
|
||||
* and redirects to the directory-relative clean `./`; a valid cookie lets
|
||||
* the caller serve the index; every other request receives the same minimal
|
||||
* 401 response.
|
||||
* @param req - incoming root or configured-index request.
|
||||
* @param res - response owned when this method returns false.
|
||||
* @returns true only when the caller may serve index.html.
|
||||
@@ -255,7 +253,7 @@ export class BrowserAuth {
|
||||
}, this.secret)
|
||||
res.writeHead(303, {
|
||||
'cache-control': 'no-store',
|
||||
'location': '/',
|
||||
'location': './',
|
||||
'referrer-policy': 'no-referrer',
|
||||
'set-cookie': sessionCookie(
|
||||
cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000),
|
||||
@@ -267,7 +265,7 @@ export class BrowserAuth {
|
||||
if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) {
|
||||
res.writeHead(303, {
|
||||
'cache-control': 'no-store',
|
||||
'location': '/',
|
||||
'location': './',
|
||||
'referrer-policy': 'no-referrer',
|
||||
})
|
||||
res.end()
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
/** Transport this caller posts through; same signature as the global `fetch`. */
|
||||
export type RpcFetch = (input: URL, init: RequestInit) => Promise<Response>
|
||||
/**
|
||||
* Transport this caller posts through; same signature as the global `fetch`.
|
||||
* Receives the document-relative route so a carrier resolves it against its own base.
|
||||
*/
|
||||
export type RpcFetch = (input: string | URL, init: RequestInit) => Promise<Response>
|
||||
|
||||
/** Worker-local opener for decoded Gateway Remote streams. */
|
||||
export type RpcStreamOpen = (
|
||||
@@ -40,8 +42,11 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea
|
||||
method: endpoint,
|
||||
payload,
|
||||
}
|
||||
// The channel key is absolute; a page posts the document-relative form, and
|
||||
// a carrier that resolves against the Host root accepts the same form.
|
||||
const route = `${channel}/${endpoint}`.slice(1)
|
||||
const response = await send(
|
||||
new URL(`${channel}/${endpoint}`, resolveBase()),
|
||||
route,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -105,11 +110,6 @@ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function resolveBase(): string {
|
||||
const location = (globalThis as { location?: { origin?: string } }).location
|
||||
return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
|
||||
}
|
||||
|
||||
function assertTarget(channel: string, endpoint: string): void {
|
||||
const segments = endpoint.split('/')
|
||||
if (!CHANNEL_PATTERN.test(channel)
|
||||
|
||||
@@ -193,8 +193,8 @@ export interface HostConnectionHandle {
|
||||
|
||||
/**
|
||||
* Add the fresh process token to an ordinary Web application URL.
|
||||
* @param baseUrl - clean canonical browser origin.
|
||||
* @returns root URL accepted by {@link authorizeIndex} for initial login.
|
||||
* @param baseUrl - clean application URL whose authority and mount are preserved.
|
||||
* @returns tokenized URL for initial login; a mount proxy strips its prefix before {@link authorizeIndex}.
|
||||
*/
|
||||
authenticatedUrl(baseUrl: string): string
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('BrowserAuth', () => {
|
||||
status: 303,
|
||||
headers: {
|
||||
'cache-control': 'no-store',
|
||||
'location': '/',
|
||||
'location': './',
|
||||
'referrer-policy': 'no-referrer',
|
||||
},
|
||||
})
|
||||
@@ -134,12 +134,35 @@ describe('BrowserAuth', () => {
|
||||
status: 303,
|
||||
headers: {
|
||||
'cache-control': 'no-store',
|
||||
'location': '/',
|
||||
'location': './',
|
||||
'referrer-policy': 'no-referrer',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the caller authority and mount while adding only this process token', async () => {
|
||||
const auth = await createAuth(new RecordCredentials())
|
||||
const mounted = new URL(auth.authenticatedUrl('https://gateway.example/tools/dsh/'))
|
||||
expect(mounted.origin).toBe('https://gateway.example')
|
||||
expect(mounted.pathname).toBe('/tools/dsh/')
|
||||
expect([...mounted.searchParams.keys()]).toEqual(['token'])
|
||||
|
||||
const loopback = new URL(auth.authenticatedUrl('http://127.0.0.1:3080/'))
|
||||
expect(loopback.origin).toBe('http://127.0.0.1:3080')
|
||||
expect(loopback.pathname).toBe('/')
|
||||
expect(loopback.searchParams.get('token')).toBe(mounted.searchParams.get('token'))
|
||||
|
||||
// The proxy preserves the browser-facing Host and strips the mount.
|
||||
const token = mounted.searchParams.get('token')
|
||||
const exchanged = response()
|
||||
expect(auth.authorizeIndex(request(`/?token=${String(token)}`, 'gateway.example'), exchanged.value)).toBe(false)
|
||||
const setCookie = exchanged.state.headers?.['set-cookie']
|
||||
if (setCookie === undefined) throw new Error('mount exchange did not set a cookie')
|
||||
expect(auth.isAuthenticated(request(
|
||||
'/', 'gateway.example', { cookie: setCookie.split(';', 1)[0]! },
|
||||
))).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => {
|
||||
const auth = await createAuth(new RecordCredentials())
|
||||
const { cookie } = exchange(auth)
|
||||
|
||||
@@ -469,7 +469,7 @@ describe('connection client apply', () => {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
|
||||
expect(seen[0]?.url).toBe('api/goals/create')
|
||||
expect(seen[0]?.body).toMatchObject({
|
||||
type: 'client-request',
|
||||
rpcId: '00000000-0000-4000-8000-000000000000',
|
||||
@@ -541,7 +541,7 @@ describe('connection client apply', () => {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
|
||||
.rejects.toThrow('HTTP 503')
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
new URL('https://harness.example/api/goals/create'),
|
||||
'api/goals/create',
|
||||
expect.objectContaining({ signal: abort.signal }),
|
||||
)
|
||||
|
||||
@@ -553,7 +553,7 @@ describe('connection client apply', () => {
|
||||
}))
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
|
||||
const fetch = vi.mocked(globalThis.fetch)
|
||||
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
|
||||
expect(fetch.mock.calls[0]?.[0]).toEqual('api/goals/create')
|
||||
expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
|
||||
|
||||
const respond = (result: unknown): void => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/** Web SSE transport for page-owned client entry reconciliation and rebuilt code replacement. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { PluginsEventParseResult } from '../events.ts'
|
||||
import { EVENTS_ENDPOINT, parsePluginsEventFrame } from '../events.ts'
|
||||
import { EVENTS_ROUTE, parsePluginsEventFrame } from '../events.ts'
|
||||
|
||||
export type { PluginsEventFrame } from '../events.ts'
|
||||
export { EVENTS_ENDPOINT } from '../events.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'client-hmr'
|
||||
@@ -26,7 +25,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
const source = new EventSource(EVENTS_ENDPOINT)
|
||||
const source = new EventSource(EVENTS_ROUTE)
|
||||
source.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let value: unknown
|
||||
try {
|
||||
|
||||
@@ -42,3 +42,9 @@ export function parsePluginsEventFrame(value: unknown): PluginsEventParseResult
|
||||
|
||||
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
|
||||
export const EVENTS_ENDPOINT = '/plugins/events'
|
||||
|
||||
/**
|
||||
* Document-relative form of {@link EVENTS_ENDPOINT} used by the browser half.
|
||||
* See .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
|
||||
*/
|
||||
export const EVENTS_ROUTE = EVENTS_ENDPOINT.slice(1)
|
||||
@@ -48,7 +48,7 @@ function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOption
|
||||
options.beforeGraphRead?.()
|
||||
return {
|
||||
rev: 'r',
|
||||
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/??${id}/client.js&rev=r`, rev: 'r' })),
|
||||
entries: [...rows.keys()].map(id => ({ id, url: `plugins/??${id}/client.js&rev=r`, rev: 'r' })),
|
||||
batches: [],
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: 5b751268430b716e7a0af24f8e25aaf2f865d079
|
||||
README.zh.md: 4152d64ca9dde60ab1aeb76c98508d8d86fcf54e
|
||||
README.md: 88d9e7bf5fec1e8d12a2e5bd3431c6815ab4a9d3
|
||||
README.zh.md: d90348004fe6d2e8c3af1dfca09022a6a5665847
|
||||
@@ -71,7 +71,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
|
||||
|
||||
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
|
||||
|
||||
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. A script body is combined once on its first `GET` and ends with its map URL. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads and caches that script, and its map remains uncomputed until the map URL is requested. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces. During development, the shared preset stamps `client.js` after every package output is written; HMR derives the next revision from the entry bytes and that completed-build stamp, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
|
||||
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads and caches that script, and its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces. During development, the shared preset stamps `client.js` after every package output is written; HMR derives the next revision from the entry bytes and that completed-build stamp, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
|
||||
|
||||
### Boot manifest injection
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
|
||||
|
||||
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
|
||||
|
||||
Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。脚本 body 在首次 `GET` 时只组合一次,并以对应 map URL 结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会读取并缓存该脚本,其 map 仍会等到 map URL 被请求后才计算。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce。开发期间,共享预设会在该包所有输出写完后标记 `client.js`;HMR 从入口字节和该构建完成标记派生下一 revision,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
|
||||
Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会读取并缓存该脚本,其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins/<package>/client.<name>.js?rev=<rev>`);chunk 脚本携带的 map trailer 则是裸文件名 `client.<name>.js.map?rev=<rev>`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce。开发期间,共享预设会在该包所有输出写完后标记 `client.js`;HMR 从入口字节和该构建完成标记派生下一 revision,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
|
||||
|
||||
### 启动 manifest 注入
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@ declare module '@deepseek-ai/cordis' {
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Revisioned single-resource combo endpoint used by HMR. */
|
||||
/**
|
||||
* Revisioned single-resource combo reference used by HMR. It is relative to
|
||||
* the document, so the browser resolves it under whatever mount served the page.
|
||||
*/
|
||||
url: string
|
||||
/** Opaque plugin-artifact revision used for HMR cache busting. */
|
||||
rev: string
|
||||
@@ -71,7 +74,7 @@ export type WebBootBatchPhase = 'bootstrap' | 'application'
|
||||
export interface WebBootBatch {
|
||||
/** Parser-blocking bootstrap or preloaded application scheduling. */
|
||||
phase: WebBootBatchPhase
|
||||
/** Revisioned combo script endpoint. */
|
||||
/** Content-addressed combo script reference, document-relative like {@link WebBootEntry.url}. */
|
||||
url: string
|
||||
/** Revision derived from the ordered entry revisions. */
|
||||
rev: string
|
||||
@@ -97,9 +100,9 @@ export interface WebBootGraph {
|
||||
export interface BootModuleRow {
|
||||
/** Entry name == package name (module-table key). */
|
||||
id: string
|
||||
/** Revisioned single-resource combo endpoint used after HMR invalidation. */
|
||||
/** Revisioned single-resource combo reference used after HMR invalidation. */
|
||||
url: string
|
||||
/** Revisioned combo endpoint used before the first HMR invalidation. */
|
||||
/** Content-addressed combo reference used before the first HMR invalidation. */
|
||||
initialUrl: string
|
||||
/** Opaque plugin-artifact revision used after HMR invalidation. */
|
||||
rev: string
|
||||
|
||||
@@ -158,15 +158,21 @@ interface LazyResponse {
|
||||
|
||||
/** Fields shared by every generated combo plan. */
|
||||
interface ComboArtifact {
|
||||
/** Absolute route URL of this response. */
|
||||
url: string
|
||||
rev: string
|
||||
entries: string[]
|
||||
/** Absolute route URL of the map response. */
|
||||
sourceMapUrl: string
|
||||
scriptBody: () => Promise<Buffer>
|
||||
sourceMapBody: () => Promise<Buffer>
|
||||
}
|
||||
|
||||
/** One generated initial-load response and its wire descriptor. */
|
||||
/**
|
||||
* One generated initial-load response and its wire descriptor. The descriptor
|
||||
* travels to the browser, so its URL is the document-relative reference while
|
||||
* {@link ComboArtifact.url} is the route key.
|
||||
*/
|
||||
type BatchArtifact = ComboArtifact & { descriptor: WebBootBatch }
|
||||
|
||||
/** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
|
||||
@@ -213,20 +219,47 @@ function artifactRevision(bundle: Buffer, baseline: ClientArtifactBaseline): str
|
||||
return framedHash('plugin-artifact', [bundle, Buffer.from(String(baseline.mtimeMs))])
|
||||
}
|
||||
|
||||
/** Address one ordered plugin-file list through the shared combo route. */
|
||||
function comboUrl(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
/** Absolute route prefix serving every plugin resource. */
|
||||
const PLUGIN_ROUTE = '/plugins'
|
||||
|
||||
/** Combo query addressing one ordered plugin-file list. */
|
||||
function comboSearch(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
|
||||
return `/plugins/??${resources}&rev=${rev}`
|
||||
return `??${resources}&rev=${rev}`
|
||||
}
|
||||
|
||||
/** Address one package-local chunk through the same revision as its entry. */
|
||||
/** Absolute route URL for one combo resource. */
|
||||
function comboUrl(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
return `${PLUGIN_ROUTE}/${comboSearch(ids, rev, sourceMap)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser reference to one combo resource: app-owned browser routes are
|
||||
* document-relative, so the route key's leading slash is stripped here, at the
|
||||
* boundary between the two halves. The rule and its reasons are owned by
|
||||
* .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
|
||||
*/
|
||||
function comboReference(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
return comboUrl(ids, rev, sourceMap).slice(1)
|
||||
}
|
||||
|
||||
/** Absolute route URL for one package-local chunk. */
|
||||
function chunkUrl(id: string, fileName: string, rev: string, sourceMap = false): string {
|
||||
return `/plugins/${id}/${fileName}${sourceMap ? '.map' : ''}?rev=${rev}`
|
||||
return `${PLUGIN_ROUTE}/${id}/${fileName}${sourceMap ? '.map' : ''}?rev=${rev}`
|
||||
}
|
||||
|
||||
/** Measure the longer map-form URL used to partition a startup resource list. */
|
||||
/**
|
||||
* Source-map reference stamped into one chunk script. A script's map reference
|
||||
* resolves against that script's own directory rather than the document, so
|
||||
* this is the bare map file name, not the document-relative route.
|
||||
*/
|
||||
function chunkMapReference(fileName: string, rev: string): string {
|
||||
return `${fileName}.map?rev=${rev}`
|
||||
}
|
||||
|
||||
/** Measure the longest browser-facing combo URL used to partition a startup resource list. */
|
||||
function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
|
||||
return Buffer.byteLength(comboUrl(
|
||||
return Buffer.byteLength(comboReference(
|
||||
records.map(record => record.entry.id),
|
||||
COMBO_REVISION_PLACEHOLDER,
|
||||
true,
|
||||
@@ -278,7 +311,7 @@ function prepareSource(resource: ComboResource): PreparedSource {
|
||||
return { source, fallbackSource }
|
||||
}
|
||||
|
||||
/** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
|
||||
/** Stamp a combo script's source-map reference onto its executable bytes. */
|
||||
function comboScript(input: string, sourceMapUrl?: string): Buffer {
|
||||
return Buffer.from(sourceMapUrl === undefined ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`)
|
||||
}
|
||||
@@ -412,12 +445,13 @@ function buildCombo(
|
||||
const entries = resources.map(resource => resource.id)
|
||||
const url = comboUrl(entries, rev)
|
||||
const sourceMapUrl = comboUrl(entries, rev, true)
|
||||
// Trailer only: it resolves against this script's own directory, not the document.
|
||||
return {
|
||||
url,
|
||||
rev,
|
||||
entries,
|
||||
sourceMapUrl,
|
||||
scriptBody: lazyBody(() => buildComboScript(resources, sourceMapUrl)),
|
||||
scriptBody: lazyBody(() => buildComboScript(resources, comboSearch(entries, rev, true))),
|
||||
sourceMapBody: lazyBody(() => buildComboSourceMap(resources, sourceMapOf)),
|
||||
}
|
||||
}
|
||||
@@ -431,15 +465,20 @@ function buildBatch(
|
||||
const artifact = buildCombo(records, sourceMapOf)
|
||||
return {
|
||||
...artifact,
|
||||
descriptor: { phase, url: artifact.url, rev: artifact.rev, entries: artifact.entries },
|
||||
descriptor: {
|
||||
phase,
|
||||
url: artifact.url.slice(1),
|
||||
rev: artifact.rev,
|
||||
entries: artifact.entries,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
|
||||
/** Graph row for one bundle rev (the reference carries the rev as its cache-busting query). */
|
||||
function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
|
||||
return {
|
||||
id,
|
||||
url: comboUrl([id], rev),
|
||||
url: comboReference([id], rev),
|
||||
rev,
|
||||
...(fields.inject !== undefined ? { inject: fields.inject } : {}),
|
||||
...(fields.immediately ? { immediately: true } : {}),
|
||||
@@ -607,7 +646,7 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
const registerWebCarrier = (webCtx: Context): void => {
|
||||
webCtx.effect(
|
||||
() => webCtx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
|
||||
() => webCtx.webServer.register({ kind: 'prefix', path: PLUGIN_ROUTE, handler: this.serveBundle }),
|
||||
'client-modules: bundle route',
|
||||
)
|
||||
}
|
||||
@@ -735,7 +774,9 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
const batchResponses = new Map<string, LazyResponse>()
|
||||
for (const artifact of artifacts) {
|
||||
batchResponses.set(artifact.descriptor.url, this.responses.get(artifact.descriptor.url) ?? {
|
||||
// The table is keyed by the absolute route the request arrives on, not by
|
||||
// the document-relative reference the graph and descriptors carry.
|
||||
batchResponses.set(artifact.url, this.responses.get(artifact.url) ?? {
|
||||
body: artifact.scriptBody,
|
||||
contentType: 'text/javascript; charset=utf-8',
|
||||
})
|
||||
@@ -1067,7 +1108,7 @@ export class ClientModuleRegistry extends Service {
|
||||
const { record, fileName, sourceMap, resourceUrl } = request
|
||||
const clientPath = join(dirname(record.meta.clientPath), fileName)
|
||||
if (!existsSync(clientPath)) return undefined
|
||||
const sourceMapUrl = chunkUrl(record.entry.id, fileName, record.entry.rev, true)
|
||||
const sourceMapUrl = chunkMapReference(fileName, record.entry.rev)
|
||||
const resource = (): ComboResource => ({
|
||||
id: record.entry.id,
|
||||
rev: record.entry.rev,
|
||||
|
||||
@@ -17,8 +17,8 @@ export const inject = ['invariants']
|
||||
/**
|
||||
* Owned relation: the node half's boot entry graph must stay self-consistent
|
||||
* — every row must resolve a clientPath under the same id (the
|
||||
* /plugins/<id>/client.js URL it advertises would otherwise 404 on a browser
|
||||
* that just received the graph). Checked on every scan trigger (cordis
|
||||
* `plugins/<id>/client.js` reference it advertises would otherwise 404 in the
|
||||
* browser that just received the graph). Checked on every scan trigger (cordis
|
||||
* 'internal/plugin'): graph() and clientPath() read the same table object,
|
||||
* so the relation holds at any instant — no need to wait out the node half's
|
||||
* own microtask-debounced flush.
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
|
||||
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
const comboUrl = (ids: readonly string[], rev: string): string =>
|
||||
`/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
const chunkUrl = (id: string, fileName: string, rev = '0'): string =>
|
||||
`/plugins/${id}/${fileName}?rev=${rev}`
|
||||
const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'bootstrap')
|
||||
const APPLICATION_URL = comboUrl(['a', 'b'], 'application')
|
||||
// The host composes graph rows and batch descriptors as app-directory-relative
|
||||
// browser references, so these fixtures carry the same form.
|
||||
const comboReference = (ids: readonly string[], rev: string): string =>
|
||||
`plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
const chunkReference = (id: string, fileName: string, rev = '0'): string =>
|
||||
`plugins/${id}/${fileName}?rev=${rev}`
|
||||
const BOOTSTRAP_URL = comboReference([MODULES_ID], 'bootstrap')
|
||||
const APPLICATION_URL = comboReference(['a', 'b'], 'application')
|
||||
const win = globalThis as DshWindow
|
||||
const bootstrapExports = { apply, createClientModuleSystem }
|
||||
|
||||
@@ -30,7 +32,7 @@ afterEach(() => {
|
||||
const row = (id: string, fields: Partial<BootModuleRow> = {}): BootModuleRow =>
|
||||
({
|
||||
id,
|
||||
url: comboUrl([id], '0'),
|
||||
url: comboReference([id], '0'),
|
||||
initialUrl: id === MODULES_ID ? BOOTSTRAP_URL : APPLICATION_URL,
|
||||
rev: '0',
|
||||
inject: [],
|
||||
@@ -85,7 +87,7 @@ function bench(
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
await new Promise<void>((resolve) => { gates.set(url, resolve) })
|
||||
}
|
||||
const sibling = /^\/plugins\/(.+)\/(client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\?rev=[^&]+$/.exec(url)
|
||||
const sibling = /^plugins\/(.+)\/(client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\?rev=[^&]+$/.exec(url)
|
||||
if (sibling !== null) {
|
||||
const id = sibling[1] as string
|
||||
const chunk = sibling[2] as string
|
||||
@@ -248,7 +250,7 @@ describe('lazy CJS arrival', () => {
|
||||
const second = await entry.load()
|
||||
expect(first).toBe(second)
|
||||
expect(first).toEqual({ marker: 'terminal' })
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, chunkUrl('a', 'client.terminal.js')])
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, chunkReference('a', 'client.terminal.js')])
|
||||
})
|
||||
|
||||
it('loads a package-local chunk with the revision that invalidated its entry', async () => {
|
||||
@@ -265,9 +267,9 @@ describe('lazy CJS arrival', () => {
|
||||
await second.load()
|
||||
expect(b.fetched).toEqual([
|
||||
APPLICATION_URL,
|
||||
chunkUrl('a', 'client.terminal.js'),
|
||||
comboUrl(['a'], 'rebuilt'),
|
||||
chunkUrl('a', 'client.terminal.js', 'rebuilt'),
|
||||
chunkReference('a', 'client.terminal.js'),
|
||||
comboReference(['a'], 'rebuilt'),
|
||||
chunkReference('a', 'client.terminal.js', 'rebuilt'),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -292,7 +294,7 @@ describe('lazy CJS arrival', () => {
|
||||
})
|
||||
|
||||
it('shares one in-flight package-local chunk transport', async () => {
|
||||
const url = chunkUrl('a', 'client.terminal.js')
|
||||
const url = chunkReference('a', 'client.terminal.js')
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ load: () => req.async('./client.terminal.js') }),
|
||||
}, {
|
||||
@@ -318,11 +320,11 @@ describe('lazy CJS arrival', () => {
|
||||
const stale = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
|
||||
b.loader.invalidate('a', 'rebuilt')
|
||||
await stale.load()
|
||||
expect(b.fetched.at(-1)).toBe(chunkUrl('a', 'client.terminal.js', 'rebuilt'))
|
||||
expect(b.fetched.at(-1)).toBe(chunkReference('a', 'client.terminal.js', 'rebuilt'))
|
||||
})
|
||||
|
||||
it('discards a chunk that arrives after its owner generation was invalidated', async () => {
|
||||
const staleUrl = chunkUrl('a', 'client.terminal.js')
|
||||
const staleUrl = chunkReference('a', 'client.terminal.js')
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ load: () => req.async('./client.terminal.js') }),
|
||||
}, {
|
||||
@@ -337,7 +339,7 @@ describe('lazy CJS arrival', () => {
|
||||
b.gates.get(staleUrl)?.()
|
||||
|
||||
await expect(staleLoad).resolves.toEqual({ marker: 'terminal' })
|
||||
expect(b.fetched).toContain(chunkUrl('a', 'client.terminal.js', 'rebuilt'))
|
||||
expect(b.fetched).toContain(chunkReference('a', 'client.terminal.js', 'rebuilt'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -472,7 +474,7 @@ describe('failure modes', () => {
|
||||
})
|
||||
|
||||
it('rejects a graph row whose one-resource URL cannot address sibling chunks', async () => {
|
||||
const b = bench([row('a', { url: '/plugins/a/client.js?rev=0' })], {
|
||||
const b = bench([row('a', { url: 'plugins/a/client.js?rev=0' })], {
|
||||
a: req => ({ load: () => req.async('./client.terminal.js') }),
|
||||
})
|
||||
const entry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
|
||||
@@ -529,14 +531,14 @@ describe('boot manifest wire', () => {
|
||||
const manifest = parseBootManifest({
|
||||
rev: 'graph',
|
||||
entries: [
|
||||
{ id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'] },
|
||||
{ id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] },
|
||||
{ id: 'a', url: 'plugins/a/client.js', rev: '1', inject: ['b'] },
|
||||
{ id: 'b', url: 'plugins/b/client.js', rev: '2', external: ['react'] },
|
||||
],
|
||||
batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['a', 'b'] }],
|
||||
batches: [{ phase: 'application', url: 'batch.js', rev: 'batch', entries: ['a', 'b'] }],
|
||||
})
|
||||
expect(manifest.modules).toEqual([
|
||||
{ id: 'a', url: '/plugins/a/client.js', initialUrl: '/batch.js', rev: '1', inject: ['b'], external: [] },
|
||||
{ id: 'b', url: '/plugins/b/client.js', initialUrl: '/batch.js', rev: '2', inject: [], external: ['react'] },
|
||||
{ id: 'a', url: 'plugins/a/client.js', initialUrl: 'batch.js', rev: '1', inject: ['b'], external: [] },
|
||||
{ id: 'b', url: 'plugins/b/client.js', initialUrl: 'batch.js', rev: '2', inject: [], external: ['react'] },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -636,7 +638,7 @@ describe('HMR reset', () => {
|
||||
expect(b.loader.loadCache.has('a')).toBe(false)
|
||||
await b.loader.prefetch('a')
|
||||
const second = await b.loader.import('a', '', {})
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, comboUrl(['a'], '1')])
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, comboReference(['a'], '1')])
|
||||
expect((first as { generation: number }).generation).toBe(1)
|
||||
expect((second as { generation: number }).generation).toBe(2)
|
||||
})
|
||||
@@ -666,7 +668,7 @@ describe('HMR reset', () => {
|
||||
await b.loader.import('a', '', {})
|
||||
b.loader.invalidate('a')
|
||||
await b.loader.prefetch('a')
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, comboUrl(['a'], '0')])
|
||||
expect(b.fetched).toEqual([APPLICATION_URL, comboReference(['a'], '0')])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -17,12 +17,21 @@ import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../sr
|
||||
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
||||
const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
|
||||
|
||||
const comboUrl = (ids: readonly string[], rev: string): string =>
|
||||
`/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
// Graph rows and batch descriptors carry app-directory-relative references;
|
||||
// a host request reaches the backend route once the document's directory
|
||||
// resolves them, which these origin-root fixtures reduce to a leading slash.
|
||||
const comboReference = (ids: readonly string[], rev: string): string =>
|
||||
`plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
||||
const mapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
|
||||
const chunkUrl = (id: string, fileName: string, rev: string): string => `/plugins/${id}/${fileName}?rev=${rev}`
|
||||
const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
|
||||
const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
|
||||
const mapReference = (url: string): string => mapUrl(url).replace(/^\/?plugins\//u, '')
|
||||
// A chunk script is served from its package's own directory, so its
|
||||
// source-map reference resolves against the chunk's own URL: the bare map
|
||||
// file name, not the route key the response table holds.
|
||||
const chunkMapReference = (fileName: string, rev: string): string => `${fileName}.map?rev=${rev}`
|
||||
const chunkReference = (id: string, fileName: string, rev: string): string =>
|
||||
`plugins/${id}/${fileName}?rev=${rev}`
|
||||
const BOOTSTRAP_URL = comboReference([MODULES_ID], 'boot')
|
||||
const APPLICATION_URL = comboReference([UI_RENDERER_ID], 'app')
|
||||
|
||||
let root: string | undefined
|
||||
const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
|
||||
@@ -183,8 +192,8 @@ function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModu
|
||||
const bootGraph = (): WebBootGraph => ({
|
||||
rev: 'graph',
|
||||
entries: [
|
||||
{ id: MODULES_ID, url: comboUrl([MODULES_ID], 'm'), rev: 'm' },
|
||||
{ id: UI_RENDERER_ID, url: comboUrl([UI_RENDERER_ID], 'r'), rev: 'r' },
|
||||
{ id: MODULES_ID, url: comboReference([MODULES_ID], 'm'), rev: 'm' },
|
||||
{ id: UI_RENDERER_ID, url: comboReference([UI_RENDERER_ID], 'r'), rev: 'r' },
|
||||
],
|
||||
batches: [
|
||||
{
|
||||
@@ -239,8 +248,8 @@ describe('HTML bootstrap facade', () => {
|
||||
it('preloads every application combo', () => {
|
||||
const graph = bootGraph()
|
||||
const secondId = '@fixture/second-application-combo'
|
||||
const secondUrl = comboUrl([secondId], 'app-2')
|
||||
graph.entries.push({ id: secondId, url: comboUrl([secondId], 'row-2'), rev: 'row-2' })
|
||||
const secondUrl = comboReference([secondId], 'app-2')
|
||||
graph.entries.push({ id: secondId, url: comboReference([secondId], 'row-2'), rev: 'row-2' })
|
||||
graph.batches.push({ phase: 'application', url: secondUrl, rev: 'app-2', entries: [secondId] })
|
||||
expect(bootInjections(graph).flatMap(row => row.kind === 'script-preload' ? [row.src] : []))
|
||||
.toEqual([APPLICATION_URL, secondUrl])
|
||||
@@ -500,7 +509,7 @@ describe('client bundle activation', () => {
|
||||
const torn = constructWithRoute([packageName])
|
||||
const tornRow = torn.service.graph().entries[0]!
|
||||
expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
|
||||
.toContain(`sourceMappingURL=${mapUrl(tornRow.url)}`)
|
||||
.toContain(`sourceMappingURL=${mapReference(tornRow.url)}`)
|
||||
const fallback = await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))
|
||||
expect(JSON.parse(fallback.body.toString('utf8'))).toMatchObject({
|
||||
sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
|
||||
@@ -579,7 +588,7 @@ describe('client bundle activation', () => {
|
||||
const stableChunkPath = join(dirname(stablePath), 'client.stable.js')
|
||||
writeFileSync(stableChunkPath, 'module.exports = { generation: 1 }\n')
|
||||
const stableRow = service.graph().entries.find(entry => entry.id === stablePackage)!
|
||||
const stableChunkUrl = chunkUrl(stablePackage, 'client.stable.js', stableRow.rev)
|
||||
const stableChunkUrl = chunkReference(stablePackage, 'client.stable.js', stableRow.rev)
|
||||
const firstChunk = await routeRequest(route, stableChunkUrl)
|
||||
|
||||
writeFileSync(`${stablePath}.map`, JSON.stringify({
|
||||
@@ -611,7 +620,7 @@ describe('client bundle activation', () => {
|
||||
const batch = service.graph().batches[0]!
|
||||
const script = (await routeRequest(route, batch.url)).body.toString('utf8')
|
||||
expect(script).not.toContain('//# sourceURL=')
|
||||
expect(script).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
|
||||
expect(script).toContain(`//# sourceMappingURL=${mapReference(batch.url)}`)
|
||||
const payload = JSON.parse((await routeRequest(route, mapUrl(batch.url))).body.toString('utf8')) as {
|
||||
sections: { map: { mappings: string; sources: string[]; sourcesContent: string[] } }[]
|
||||
}
|
||||
@@ -716,7 +725,7 @@ describe('client bundle activation', () => {
|
||||
}
|
||||
for (let index = 0; index < batches.length - 1; index += 1) {
|
||||
const entries = [...batches[index]!.entries, batches[index + 1]!.entries[0]!]
|
||||
expect(Buffer.byteLength(mapUrl(comboUrl(entries, '0'.repeat(12))))).toBeGreaterThan(3 * 1024)
|
||||
expect(Buffer.byteLength(mapUrl(comboReference(entries, '0'.repeat(12))))).toBeGreaterThan(3 * 1024)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -730,7 +739,7 @@ describe('client bundle activation', () => {
|
||||
const { service, route } = constructWithRoute([packageName])
|
||||
const row = service.graph().entries[0]!
|
||||
const singleScript = await routeRequest(route, row.url)
|
||||
expect(singleScript.body.toString('utf8')).toContain(`sourceMappingURL=${mapUrl(row.url)}`)
|
||||
expect(singleScript.body.toString('utf8')).toContain(`sourceMappingURL=${mapReference(row.url)}`)
|
||||
const singleMap = await routeRequest(route, mapUrl(row.url))
|
||||
expect(singleMap.status).toBe(200)
|
||||
expect(singleMap.headers).toEqual({
|
||||
@@ -754,8 +763,8 @@ describe('client bundle activation', () => {
|
||||
const batchScript = await routeRequest(route, batch.url)
|
||||
expect(batchScript.status).toBe(200)
|
||||
expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
|
||||
expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
|
||||
const shellResponse = await service.fetchBundle(new Request(`dsh-app://app${batch.url}`))
|
||||
expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapReference(batch.url)}`)
|
||||
const shellResponse = await service.fetchBundle(new Request(`dsh-app://app/${batch.url}`))
|
||||
expect(shellResponse.status).toBe(200)
|
||||
expect(shellResponse.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
|
||||
expect(await shellResponse.text()).toBe(batchScript.body.toString('utf8'))
|
||||
@@ -800,17 +809,17 @@ describe('client bundle activation', () => {
|
||||
const startup = await routeRequest(route, row.url)
|
||||
expect(startup.status).toBe(200)
|
||||
expect(startup.body.toString('utf8')).not.toContain('terminal loaded')
|
||||
expect((await routeRequest(route, chunkUrl(packageName, 'client.terminal.js', row.rev))).status).toBe(404)
|
||||
expect((await routeRequest(route, chunkReference(packageName, 'client.terminal.js', row.rev))).status).toBe(404)
|
||||
|
||||
writeFileSync(chunkPath, 'module.exports = { marker: "terminal loaded" }\n')
|
||||
const url = chunkUrl(packageName, 'client.terminal.js', row.rev)
|
||||
const url = chunkReference(packageName, 'client.terminal.js', row.rev)
|
||||
const head = await routeRequest(route, url, 'HEAD')
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.body).toHaveLength(0)
|
||||
const chunk = await routeRequest(route, url)
|
||||
expect(chunk.status).toBe(200)
|
||||
expect(chunk.body.toString('utf8')).toContain('terminal loaded')
|
||||
expect(chunk.body.toString('utf8')).toContain(`sourceMappingURL=${url.replace('.js?', '.js.map?')}`)
|
||||
expect(chunk.body.toString('utf8')).toContain(`sourceMappingURL=${chunkMapReference('client.terminal.js', row.rev)}`)
|
||||
expect((await routeRequest(route, url.replace('.js?', '.js.map?'))).status).toBe(200)
|
||||
expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
|
||||
})
|
||||
@@ -824,7 +833,7 @@ describe('client bundle activation', () => {
|
||||
writeFileSync(chunkPath, 'module.exports = { generation: 1 }\n')
|
||||
const { service, route } = constructWithRoute([packageName])
|
||||
const firstRow = service.graph().entries[0]!
|
||||
const firstUrl = chunkUrl(packageName, 'client.terminal.js', firstRow.rev)
|
||||
const firstUrl = chunkReference(packageName, 'client.terminal.js', firstRow.rev)
|
||||
expect((await routeRequest(route, firstUrl)).body.toString('utf8')).toContain('generation: 1')
|
||||
|
||||
writeFileSync(chunkPath, 'module.exports = { generation: 2 }\n')
|
||||
@@ -834,7 +843,7 @@ describe('client bundle activation', () => {
|
||||
const nextRev = service.rebuilt(packageName)!
|
||||
expect(nextRev).not.toBe(firstRow.rev)
|
||||
expect((await routeRequest(route, firstUrl)).status).toBe(404)
|
||||
const nextUrl = chunkUrl(packageName, 'client.terminal.js', nextRev)
|
||||
const nextUrl = chunkReference(packageName, 'client.terminal.js', nextRev)
|
||||
expect((await routeRequest(route, nextUrl)).body.toString('utf8')).toContain('generation: 2')
|
||||
})
|
||||
|
||||
@@ -933,12 +942,17 @@ describe('shared module declarations', () => {
|
||||
it('accepts external requests and carries them onto the graph row', () => {
|
||||
const packageName = '@fixture/shared-declared'
|
||||
writeBuiltPackage(packageName, { external: ['react'] })
|
||||
expect(construct([packageName]).graph().entries).toEqual([{
|
||||
const { service } = constructWithRoute([packageName])
|
||||
const row = service.graph().entries[0]
|
||||
if (row === undefined) throw new Error('shared-declared graph row missing')
|
||||
// The browser resolves this reference against its own document; the
|
||||
// response table still answers the absolute route it resolves to.
|
||||
expect(row).toEqual({
|
||||
id: packageName,
|
||||
url: expect.stringContaining(`/plugins/??${packageName}/client.js&rev=`) as unknown as string,
|
||||
rev: expect.any(String) as unknown as string,
|
||||
url: `plugins/??${packageName}/client.js&rev=${row.rev}`,
|
||||
rev: expect.any(String) as unknown,
|
||||
external: ['react'],
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
it('omits external when the package declares no requests', () => {
|
||||
@@ -958,7 +972,7 @@ describe('shared module declarations', () => {
|
||||
|
||||
describe('module graph order', () => {
|
||||
const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
|
||||
({ id, url: comboUrl([id], '0'), rev: '0', ...fields })
|
||||
({ id, url: comboReference([id], '0'), rev: '0', ...fields })
|
||||
const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
|
||||
|
||||
it('places every requested package row before its consumers along a chain', () => {
|
||||
|
||||
@@ -181,7 +181,7 @@ export function discoverInspectorClientSourceCatalog(): ClientSourceCatalog | un
|
||||
return Reflect.get(value, 'id') === PACKAGE_ID
|
||||
}) as Record<string, unknown> | undefined
|
||||
if (row === undefined || typeof row.url !== 'string' || typeof row.rev !== 'string') return undefined
|
||||
const base = browserLocation()
|
||||
const base = documentBase()
|
||||
if (base === undefined) return undefined
|
||||
const sourceUrl = new URL(row.url, base)
|
||||
const sourceMapUrl = new URL(sourceUrl.href)
|
||||
@@ -203,7 +203,16 @@ async function fetchText(url: string): Promise<string> {
|
||||
return response.text()
|
||||
}
|
||||
|
||||
function browserLocation(): string | undefined {
|
||||
/**
|
||||
* Base every app-owned route reference resolves against: the document's
|
||||
* `baseURI`, else the location URL, else undefined outside a browser.
|
||||
*/
|
||||
function documentBase(): string | undefined {
|
||||
const document = Reflect.get(globalThis, 'document') as unknown
|
||||
if (typeof document === 'object' && document !== null) {
|
||||
const baseURI = Reflect.get(document, 'baseURI') as unknown
|
||||
if (typeof baseURI === 'string' && baseURI !== '') return baseURI
|
||||
}
|
||||
const location = Reflect.get(globalThis, 'location') as unknown
|
||||
if (typeof location !== 'object' || location === null) return undefined
|
||||
const href = Reflect.get(location, 'href') as unknown
|
||||
@@ -224,7 +233,7 @@ function renderError(error: unknown): string {
|
||||
|
||||
function normalizedUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value, browserLocation())
|
||||
const url = new URL(value, documentBase())
|
||||
url.hash = ''
|
||||
return url.href
|
||||
} catch {
|
||||
|
||||
@@ -366,7 +366,7 @@ describe('experimental Inspector Client plugin', () => {
|
||||
rev: 'graph',
|
||||
entries: [{
|
||||
id: '@deepseek-ai/dsh-experimental-inspector',
|
||||
url: '/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev',
|
||||
url: 'plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev',
|
||||
rev: 'bundle-rev',
|
||||
}],
|
||||
})
|
||||
|
||||
@@ -294,7 +294,8 @@ export class WorkerTunnel {
|
||||
* The image packs each bundle with a trailing `sourceURL` naming its image
|
||||
* path, so the blob shows under that name in the debugger instead of as an
|
||||
* anonymous blob entry.
|
||||
* @param url - Graph combo URL (`/plugins/??<id>/client.js&rev=...`).
|
||||
* @param url - Graph combo reference (`plugins/??<id>/client.js&rev=...`), which
|
||||
* this tunnel resolves against the page origin it maps from.
|
||||
*/
|
||||
async loadBundle(url: string): Promise<void> {
|
||||
const response = await this.fetch(url)
|
||||
|
||||
@@ -9,8 +9,8 @@ afterEach(() => {
|
||||
|
||||
it('ignores script preload hints and executes script sources through the worker loader', async () => {
|
||||
const loadScript = vi.fn(async () => {})
|
||||
const preload = '/plugins/??app-a/client.js,app-b/client.js&rev=app'
|
||||
const bootstrap = '/plugins/??modules/client.js&rev=boot'
|
||||
const preload = 'plugins/??app-a/client.js,app-b/client.js&rev=app'
|
||||
const bootstrap = 'plugins/??modules/client.js&rev=boot'
|
||||
|
||||
await applyIndexInjections([
|
||||
{ kind: 'script-preload', src: preload },
|
||||
|
||||
@@ -30,6 +30,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
it('loads a combo map through the tunnel and embeds it in the blob script', async () => {
|
||||
document.head.innerHTML = '<base href="/static/preview/">'
|
||||
const { worker, sent, deliver } = stubWorker()
|
||||
const tunnel = new WorkerTunnel(worker)
|
||||
const blobs: Blob[] = []
|
||||
@@ -52,10 +53,11 @@ it('loads a combo map through the tunnel and embeds it in the blob script', asyn
|
||||
}
|
||||
})
|
||||
|
||||
const scriptUrl = '/plugins/??a/client.js,b/client.js&rev=abc'
|
||||
const mapUrl = '/plugins/??a/client.js.map,b/client.js.map&rev=abc'
|
||||
// The worker's Host starts at '/', independently of the preview document directory.
|
||||
const scriptUrl = 'plugins/??a/client.js,b/client.js&rev=abc'
|
||||
const mapUrl = '??a/client.js.map,b/client.js.map&rev=abc'
|
||||
const loading = tunnel.loadBundle(scriptUrl)
|
||||
expect(sent[0]?.url).toBe(`http://localhost:3000${scriptUrl}`)
|
||||
expect(sent[0]?.url).toBe(`http://localhost:3000/${scriptUrl}`)
|
||||
deliver({
|
||||
t: 'res',
|
||||
id: 1,
|
||||
@@ -64,7 +66,7 @@ it('loads a combo map through the tunnel and embeds it in the blob script', asyn
|
||||
body: new TextEncoder().encode(`factory();\n//# sourceMappingURL=${mapUrl}\n`).buffer,
|
||||
})
|
||||
await vi.waitFor(() => { expect(sent).toHaveLength(2) })
|
||||
expect(sent[1]?.url).toBe(`http://localhost:3000${mapUrl}`)
|
||||
expect(sent[1]?.url).toBe(`http://localhost:3000/plugins/${mapUrl}`)
|
||||
const map = '{"version":3,"sections":[]}'
|
||||
deliver({
|
||||
t: 'res',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/frontend-static/README.md
|
||||
README.md: e616c048119fc1ce75ad29b002c3298b84169af9
|
||||
README.zh.md: 6cf55bb6d82f665754767f0a8c5b9cc68d8236d6
|
||||
README.md: e9bf19b48acfbc8c1513b2b64226e17faf1dc2de
|
||||
README.zh.md: 3021d6de08c6ab04732389423d0ae96846992dfe
|
||||
@@ -41,6 +41,8 @@ Compose this plugin in a browser-facing host that serves the built Web shell: it
|
||||
|
||||
Requests are served from the dist root (the directory containing `distIndex`). The dist root and the configured index path render `index.html` with HTTP 200; any other existing file is served directly with its MIME type, and unknown extensions ship as `application/octet-stream`. A path that resolves outside the root is rejected with 403, so a crafted path cannot read files above the dist. An absent or non-file target inside the dist root — a missing file, a directory, or a missing configured index — returns an empty 404. Non-GET/HEAD requests without a matching named route are answered 405. Every successful index response is rendered through the webserver's `renderIndex`, so the boot manifest reaches the page on `/` and on the configured index path.
|
||||
|
||||
The served HTML carries one document base, `<base href="./">`, ahead of every injected resource row, so it freezes the entry directory the page was loaded from: the shell's own app-directory-relative references and the Host's plugin-resource rows both resolve under the mount that served the page. The same index therefore serves the origin root and whatever mount a prefix-stripping proxy owns; this plugin renders it only for the dist root and the configured index path.
|
||||
|
||||
Root and configured-index responses call `ctx.connection.authorizeIndex` before reading HTML. A valid process token receives a 303 redirect plus the persistent browser cookie; an existing valid cookie serves the index; every other index request receives the Connection-owned 401 response. Non-index files remain public static assets. Connection owns the token, cookie, expiry, and signing-record semantics.
|
||||
|
||||
### Observable failures
|
||||
@@ -57,7 +59,7 @@ Traversal returns 403 rather than an error page. An absent or non-file target in
|
||||
|
||||
### Design concept
|
||||
|
||||
The package is one function plugin around `serveStatic`: `apply` resolves the dist root from `distIndex`, builds a `renderIndex` closure that runs `ctx.webServer.renderIndex` over the raw `index.html`, and registers the fallback handler under an effect scope. The seat is single-owner by the webserver's contract — a second registration throws — and effect-scoped, so disposing the fiber releases the seat.
|
||||
The package is one function plugin around `serveStatic`: `apply` resolves the dist root from `distIndex`, builds a `renderIndex` closure that runs `ctx.webServer.renderIndex` over the raw `index.html` and splices the document base in after the opening head tag (after the raw taps, so it precedes their markup too), and registers the fallback handler under an effect scope. The seat is single-owner by the webserver's contract — a second registration throws — and effect-scoped, so disposing the fiber releases the seat.
|
||||
|
||||
### The traversal fence
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ kind: "package-reference"
|
||||
|
||||
请求从 dist 根目录(包含 `distIndex` 的目录)提供。dist 根目录与配置的 index 路径以 HTTP 200 渲染 `index.html`;任何其他已有文件按自身 MIME 类型直接提供,未知扩展名按 `application/octet-stream` 提供。解析到根目录之外的路径以 403 拒绝,因此精心构造的路径无法读取 dist 之上的文件。dist 根目录内不存在或不是文件的目标——文件缺失、目录或配置的 index 缺失——返回空 404。没有匹配具名路由的非 GET/HEAD 请求返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 渲染,因此启动 manifest(元数据清单)会通过 `/` 与配置的 index 路径送达页面。
|
||||
|
||||
所服务的 HTML 携带唯一的文档 base `<base href="./">`,位于每一条注入资源行之前,因此它冻结页面加载时所处的入口目录:shell 自身的应用目录相对引用与宿主的插件资源行都在服务该页面的挂载下解析。同一份 index 因而既服务源站根目录,也服务剥离前缀的代理所拥有的任一挂载;本插件只为 dist 根目录与配置的 index 路径渲染它。
|
||||
|
||||
根路径与配置的 index 响应会在读取 HTML 前调用 `ctx.connection.authorizeIndex`。有效进程 token 会得到 303 重定向与持久浏览器 cookie;已有有效 cookie 时直接提供 index;其他 index 请求得到 Connection 所有的 401 响应。非 index 文件仍是公开静态资源。Token、cookie、过期时间与签名记录语义都归 Connection 所有。
|
||||
|
||||
### 可观察的失败
|
||||
@@ -57,7 +59,7 @@ kind: "package-reference"
|
||||
|
||||
### 设计理念
|
||||
|
||||
本包是围绕 `serveStatic` 的一个函数插件:`apply` 从 `distIndex` 解析出 dist 根目录,构建一个对原始 `index.html` 运行 `ctx.webServer.renderIndex` 的 `renderIndex` 闭包,并在 effect 作用域下注册回退 handler。按 webserver 的约定,席位只有单一所有者——第二次注册会抛错——且受 effect 作用域约束,因此 dispose fiber 即释放席位。
|
||||
本包是围绕 `serveStatic` 的一个函数插件:`apply` 从 `distIndex` 解析出 dist 根目录,构建一个对原始 `index.html` 运行 `ctx.webServer.renderIndex` 并把文档 base 拼接在起始 head 标签之后的 `renderIndex` 闭包(在原始转换之后执行,因此也先于其标记),并在 effect 作用域下注册回退 handler。按 webserver 的约定,席位只有单一所有者——第二次注册会抛错——且受 effect 作用域约束,因此 dispose fiber 即释放席位。
|
||||
|
||||
### 遍历栅栏
|
||||
|
||||
|
||||
@@ -113,13 +113,10 @@ export async function serveStatic(
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const distIndex = config.distIndex
|
||||
const distRoot = dirname(distIndex)
|
||||
// The dist is built with a relative base so the same files mount under any
|
||||
// static directory; served pages also answer deep SPA-fallback paths, where
|
||||
// relative asset URLs would resolve under the request directory, so the
|
||||
// served form anchors them at the site root ahead of every URL-bearing tag.
|
||||
// Insert after all index transforms so the base precedes every resource reference.
|
||||
const renderIndex = async (): Promise<string> => {
|
||||
const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'))
|
||||
return body.replace(/<head(?:\s[^>]*)?>/i, open => `${open}<base href="/">`)
|
||||
return body.replace(/<head(?:\s[^>]*)?>/i, open => `${open}<base href="./">`)
|
||||
}
|
||||
ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => {
|
||||
// Non-GET/HEAD without a matching named route is 405 (fallback-only
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('real Loader composition', () => {
|
||||
const launchUrl = loaded.connection.authenticatedUrl(`http://127.0.0.1:${String(port)}`)
|
||||
const exchange = await fetch(launchUrl, { redirect: 'manual' })
|
||||
expect(exchange.status).toBe(303)
|
||||
expect(exchange.headers.get('location')).toBe('/')
|
||||
expect(exchange.headers.get('location')).toBe('./')
|
||||
const setCookie = exchange.headers.get('set-cookie')
|
||||
if (setCookie === null) throw new Error('authenticated frontend did not set a cookie')
|
||||
const cookie = setCookie.split(';', 1)[0]!
|
||||
@@ -141,13 +141,26 @@ describe('real Loader composition', () => {
|
||||
|
||||
// Only the root and index path render index.html through registered taps.
|
||||
const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
|
||||
// A plugin row stands in for the Host's plugin-resource rows: the base the
|
||||
// shell inserts must precede it, not merely exist.
|
||||
const offRows = loaded.on('webserver/index-inject', (rows) => {
|
||||
rows.push({ kind: 'script-preload', src: 'plugins/boot.js' })
|
||||
})
|
||||
for (const path of ['/', '/index.html', '/?view=test']) {
|
||||
const got = await request(port, path, authenticated())
|
||||
expect(got.status).toBe(200)
|
||||
expect(got.type).toBe('text/html; charset=utf-8')
|
||||
expect(got.body).toContain('__T__')
|
||||
expect(got.body).toContain('shell')
|
||||
// The served document carries the entry-directory base exactly once, and
|
||||
// it precedes every injected row and tap markup, so the shell's
|
||||
// app-owned routes and the Host's resource rows resolve under one mount.
|
||||
expect(got.body.match(/<base\b/g)).toHaveLength(1)
|
||||
const base = got.body.indexOf('<base href="./">')
|
||||
expect(base).toBeLessThan(got.body.indexOf('<link rel="preload" as="script" href="plugins/boot.js">'))
|
||||
expect(base).toBeLessThan(got.body.indexOf('<script>window.__T__=1</script>'))
|
||||
}
|
||||
offRows()
|
||||
expect(await request(port, '/', authenticated({ method: 'HEAD' }))).toEqual({
|
||||
status: 200,
|
||||
type: 'text/html; charset=utf-8',
|
||||
|
||||
@@ -20,7 +20,7 @@ export type IndexInjection =
|
||||
/**
|
||||
* External classic script, executed in table order: a parser-blocking tag
|
||||
* when served, an awaited fetch-and-execute in the worker form (whose
|
||||
* loader resolves worker-only URLs such as `/plugins/...`).
|
||||
* loader resolves worker-only references such as `plugins/...`).
|
||||
*/
|
||||
| { kind: 'script-src'; placement: IndexInjectionPlacement; src: string }
|
||||
/** Advisory preload for an external classic script; static workers may ignore it. */
|
||||
|
||||
@@ -24,17 +24,17 @@ const LOCAL_REV = 'local'
|
||||
|
||||
/**
|
||||
* Synthesize the raw `WebBootGraph` for `rows`: one `application` batch
|
||||
* holding every row, `rev: 'local'`, placeholder `/plugins/<name>/client.js`
|
||||
* URLs, since every module is seeded in process and never fetched. Validation
|
||||
* stays with the production `parseBootManifest` inside the module system:
|
||||
* duplicate names and an empty roster are rejected there, not here.
|
||||
* holding every row, `rev: 'local'`, placeholder `plugins/<name>/client.js`
|
||||
* references, since every module is seeded in process and never fetched.
|
||||
* Validation stays with the production `parseBootManifest` inside the module
|
||||
* system: duplicate names and an empty roster are rejected there, not here.
|
||||
* @param rows - roster rows in composition order.
|
||||
* @returns the unparsed graph, as `createClientModuleSystem` consumes it.
|
||||
*/
|
||||
export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph {
|
||||
const entries: WebBootEntry[] = rows.map(row => ({
|
||||
id: row.name,
|
||||
url: `/plugins/${row.name}/client.js`,
|
||||
url: `plugins/${row.name}/client.js`,
|
||||
rev: LOCAL_REV,
|
||||
...(row.inject.length > 0 ? { inject: [...row.inject] } : {}),
|
||||
...(row.immediately ? { immediately: true } : {}),
|
||||
@@ -44,7 +44,7 @@ export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph
|
||||
entries,
|
||||
batches: [{
|
||||
phase: 'application',
|
||||
url: '/plugins/local.js',
|
||||
url: 'plugins/local.js',
|
||||
rev: LOCAL_REV,
|
||||
entries: entries.map(entry => entry.id),
|
||||
}],
|
||||
|
||||
@@ -25,8 +25,8 @@ const row = (name: string, immediately = false) => ({ name, inject: [], immediat
|
||||
function graph(names: readonly string[]): WebBootGraph {
|
||||
return {
|
||||
rev: 'local',
|
||||
entries: names.map(id => ({ id, url: `/plugins/${id}/client.js`, rev: 'local' })),
|
||||
batches: [{ phase: 'application', url: '/plugins/all.js', rev: 'local', entries: [...names] }],
|
||||
entries: names.map(id => ({ id, url: `plugins/${id}/client.js`, rev: 'local' })),
|
||||
batches: [{ phase: 'application', url: 'plugins/all.js', rev: 'local', entries: [...names] }],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ describe('createInProcessModules', () => {
|
||||
|
||||
it('rejects loudly instead of fetching when a graph row has no loaded module', async () => {
|
||||
const system = createInProcessModules(graph(['ghost']), new Map())
|
||||
await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)')
|
||||
await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)')
|
||||
await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (plugins/all.js)')
|
||||
await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (plugins/all.js)')
|
||||
})
|
||||
|
||||
it('exposes the graph parsed by the production validator as its manifest', () => {
|
||||
|
||||
@@ -11,22 +11,22 @@ const ROWS: readonly ClientRosterRow[] = [
|
||||
]
|
||||
|
||||
describe('graphFromRoster', () => {
|
||||
it('synthesizes one application batch over every row with placeholder URLs', () => {
|
||||
it('synthesizes one application batch over every row with placeholder references', () => {
|
||||
expect(graphFromRoster(ROWS)).toEqual({
|
||||
rev: 'local',
|
||||
entries: [
|
||||
{ id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true },
|
||||
{ id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' },
|
||||
{ id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true },
|
||||
{ id: MODULES_PACKAGE, url: `plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true },
|
||||
{ id: '@x/a', url: 'plugins/@x/a/client.js', rev: 'local' },
|
||||
{ id: '@x/b', url: 'plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true },
|
||||
],
|
||||
batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }],
|
||||
batches: [{ phase: 'application', url: 'plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => {
|
||||
const manifest = parseBootManifest(graphFromRoster(ROWS))
|
||||
expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately })))
|
||||
expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js'])
|
||||
expect(manifest.modules.map(row => row.initialUrl)).toEqual(['plugins/local.js', 'plugins/local.js', 'plugins/local.js'])
|
||||
const row = ROWS[1]!
|
||||
expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"')
|
||||
expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array')
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"apps/web/tests/default-product-isolation.e2e.ts",
|
||||
"apps/web/tests/diff-context.e2e.ts",
|
||||
"apps/web/tests/scaffold.ts",
|
||||
"apps/web/tests/prefix-proxy.ts",
|
||||
"apps/web/tests/auto-review-fixture.ts",
|
||||
"apps/web/tests/auto-review-denial.e2e.ts",
|
||||
"apps/web/tests/scaffold-generation.spec.ts",
|
||||
@@ -52,6 +53,7 @@
|
||||
"apps/web/tests/onboarding-deepseek-config.e2e.ts",
|
||||
"apps/web/tests/onboarding-usable-provider.e2e.ts",
|
||||
"apps/web/tests/remote-welcome.e2e.ts",
|
||||
"apps/web/tests/public-mount.e2e.ts",
|
||||
"apps/web/tests/workspace-new-session-folding.e2e.ts",
|
||||
"apps/web/tests/workspace-management.e2e.ts",
|
||||
"apps/web/tests/workspace-recency.e2e.ts",
|
||||
|
||||
Reference in new issue
Block a user