fix(web): resolve feature routes from the document directory and gate browser routes
This commit is contained in:
1 parent
eeb9b03465
commit
29182514ed
45 files changed
+867
-210
No files matched your search
+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-17-web-feature-routes-and-route-gate.md
|
||||
2026-09-17-web-feature-routes-and-route-gate.md: 5c18e23e827c4556c0012147f857f214ecc5641a
|
||||
2026-09-17-web-feature-routes-and-route-gate.zh.md: 2ab045e288e61eb9ba8dc6df222bf3048a11363a
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Feature routes follow the document base, and a gate keeps them there
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-web-feature-routes-and-route-gate.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Web app-owned routes are document-relative](2026-09-14-web-document-relative-app-routes.md) makes the shell, its plugin bundles, and its streams reach one listener under any mount. Routes owned by feature packages still addressed the origin root: `/open-in-app/...`, the deliverables `present.*` and `changes.*` routes, the session-log export download, the upload worker's `/api/session/uploadFileBinary` post, and the `/api/file` route behind local markdown image paths. Under a prefix-stripping proxy each of those requests missed. Nothing stopped a new browser-face reference from binding the bundle to one mount again.
|
||||
|
||||
## Decision
|
||||
|
||||
Every feature producer keeps its absolute registration key and derives the browser form beside it: `OPEN_IN_APP_*_PATH`/`*_ROUTE`, `PRESENT_*_PATH`/`*_ROUTE`, `CHANGED_FILES_PATH` and `CHANGES_*_PATH`/`*_ROUTE`, `SESSION_LOG_EXPORT_PATH`/`_ROUTE`, `FILE_UPLOAD_PATH`/`_ROUTE`. Browser code addresses only the `*_ROUTE` form, and a Fetch-shaped carrier a page installs (`FileUploadFetch`, `RpcFetch`) receives that relative route and resolves it against its own base.
|
||||
|
||||
Two consumers need an absolute URL and resolve against `document.baseURI`: the upload worker, whose own base is a `blob:` URL, and the markdown image vocabulary, which emits only absolute `http(s)`, `blob`, or `data` destinations.
|
||||
|
||||
`verify-client-route-resolution` runs in `hygiene` and CI static checks over every browser source the client compiler face compiles. It governs request targets — the first argument of a request constructor, a `fetch`-shaped call, or a dynamic import; an assigned resource property; or a JSX `src`/`href` attribute — and rejects a root-absolute, protocol-relative, or absolute app route (`api`, `plugins`, `open-in-app` prefixes), and a relative app route resolved against a `location` read. A shared `*_PATH`/`*_ENDPOINT` key used as a request target must be stripped. The same pass checks the Host producers of browser references: a `url`, `src`, or `href` field stamped with a root-absolute app route is rejected, while route keys and response-table keys stay absolute and out of scope.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Banning every `location` read in browser code.** Rejected: identity comparisons, the preview's own query read, and the worker tunnel's host-relative mapping are all legitimate. The gate governs request targets, which is where a mount-bound route appears.
|
||||
|
||||
**Rewriting feature responses in the proxy.** Rejected: it duplicates the mount in deployment configuration for each route the proxy does not know about, and the document already resolves relative references.
|
||||
|
||||
## Consequences
|
||||
|
||||
The gate reads inline literal fragments only, so a composed reference (`comboReference(...)`, `artifact.url.slice(1)`) is not traced; `packages/client/modules/tests/node-half.client.spec.ts` owns the regression signal for composed graph rows, batch descriptors, and the source-map trailer. Preview pages do not expose their worker's file API at the document URL, so markdown local-file image URLs cannot reach that Host. [Session prose local media display](../feature/2026-09-07-session-prose-local-media-display.md) owns the image vocabulary.
|
||||
|
||||
## Testing
|
||||
|
||||
The gate's own spec pins a negative control per rule: interpolated targets (`${origin}/api/file`), protocol-relative and absolute forms, an unstripped shared key, a relative route resolved against a `location` read, and root-absolute producer fields. Each feature package's specs assert the relative route its browser half sends; `markdown-images.e2e.ts` keeps the local-image path covered end to end.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 功能路由遵循文档 base,并由门禁维持
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-web-feature-routes-and-route-gate.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[Web 应用自有路由相对文档寻址](2026-09-14-web-document-relative-app-routes.zh.md)让外壳、插件 bundle 与流在任一挂载下到达同一监听端口。功能包自有的路由仍寻址源站根:`/open-in-app/...`、deliverables 的 `present.*` 与 `changes.*` 路由、会话日志导出下载、上传 worker 的 `/api/session/uploadFileBinary` 提交,以及本地 markdown 图片路径背后的 `/api/file` 路由。在剥离前缀的代理之后,这些请求全部落空。也没有任何机制阻止新的浏览器侧引用再次把 bundle 绑定到单一挂载。
|
||||
|
||||
## 决策
|
||||
|
||||
每个功能生产者保留其绝对注册键,并在旁边派生浏览器形式:`OPEN_IN_APP_*_PATH`/`*_ROUTE`、`PRESENT_*_PATH`/`*_ROUTE`、`CHANGED_FILES_PATH` 与 `CHANGES_*_PATH`/`*_ROUTE`、`SESSION_LOG_EXPORT_PATH`/`_ROUTE`、`FILE_UPLOAD_PATH`/`_ROUTE`。浏览器代码只寻址 `*_ROUTE` 形式;页面安装的 Fetch 形载体(`FileUploadFetch`、`RpcFetch`)收到该相对路由,并相对自身基准解析。
|
||||
|
||||
两个消费方需要绝对 URL,并相对 `document.baseURI` 解析:上传 worker(其自身基准是 `blob:` URL)与 markdown 图片词汇表(只输出绝对的 `http(s)`、`blob` 或 `data` 目标)。
|
||||
|
||||
`verify-client-route-resolution` 在 `hygiene` 与 CI 静态检查中运行,覆盖 client 编译面编译的每个浏览器源文件。它治理请求目标——请求构造器、`fetch` 形调用或动态 import 的第一个参数;赋值的资源属性;JSX 的 `src`/`href` 属性——拒绝根绝对、协议相对或绝对的应用路由(`api`、`plugins`、`open-in-app` 前缀),以及相对 `location` 读取解析的相对应用路由。作为请求目标使用的共享 `*_PATH`/`*_ENDPOINT` 键必须先剥去前导斜杠。同一遍检查还覆盖宿主侧浏览器引用的生产者:以根绝对应用路由写入的 `url`、`src` 或 `href` 字段被拒绝,而路由键与响应表键保持绝对,不在范围内。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**在浏览器代码中禁止一切 `location` 读取。** 否决:身份比较、preview 自身查询读取、worker tunnel 的宿主相对映射都属正当。门禁治理请求目标——绑定挂载点的路由真正出现的位置。
|
||||
|
||||
**在代理中改写功能响应。** 否决:这会为代理并不知晓的每条路由在部署配置里重复挂载点,而文档本就能解析相对引用。
|
||||
|
||||
## 后果
|
||||
|
||||
门禁只读取内联字面片段,因此组合出来的引用(`comboReference(...)`、`artifact.url.slice(1)`)不被追踪;组合而成的启动图 row、批次描述符与 source-map trailer 的回归信号由 `packages/client/modules/tests/node-half.client.spec.ts` 持有。Preview 页面不会在文档 URL 下暴露其 worker 的文件 API,因此 markdown 本地文件图片 URL 无法到达该 Host。[会话正文本地媒体展示](../feature/2026-09-07-session-prose-local-media-display.zh.md)持有图片词汇表。
|
||||
|
||||
## 测试
|
||||
|
||||
门禁自身的 spec 为每条规则固定了负向对照:插值目标(`${origin}/api/file`)、协议相对与绝对形式、未剥前导斜杠的共享键、相对路由相对 `location` 读取解析,以及生产者中根绝对的引用字段。各功能包的 spec 断言其浏览器半侧发出的相对路由;`markdown-images.e2e.ts` 端到端维持本地图片路径的覆盖。
|
||||
+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/feature/2026-09-07-session-prose-local-media-display.md
|
||||
2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd
|
||||
2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840
|
||||
2026-09-07-session-prose-local-media-display.md: fe769d1cbe0b54eed355a7e699bf853a497820cb
|
||||
2026-09-07-session-prose-local-media-display.zh.md: 2cfff4016f42a13d94a1097602ff2dadbd28d2a7
|
||||
@@ -14,7 +14,7 @@ Local media paths in Session prose render through a same-origin file route. This
|
||||
|
||||
`ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again.
|
||||
|
||||
`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert.
|
||||
`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `api/file?path=…` resolved against `document.baseURI`, so the request stays under whatever mount the served document came from. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert.
|
||||
|
||||
`session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Session 正文中的本地媒体路径通过同源文件路由渲染。本记录
|
||||
|
||||
`ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。
|
||||
|
||||
`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。
|
||||
`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到相对 `document.baseURI` 解析的 `api/file?path=…`,因此请求始终停留在所服务文档来源的那个挂载之下。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。
|
||||
|
||||
`session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"verify-npm-install-layout": "tsx scripts/verify-npm-install-layout.ts",
|
||||
"verify-client-packages": "tsx scripts/verify-client-packages.ts",
|
||||
"verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
|
||||
"verify-client-route-resolution": "tsx scripts/verify-client-route-resolution.ts",
|
||||
"verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts",
|
||||
"verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
|
||||
"rescope-vendor": "tsx scripts/rescope-vendor.ts",
|
||||
|
||||
@@ -5,13 +5,14 @@ import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { FILE_UPLOAD_PATH } from '../protocol.ts'
|
||||
import { FILE_UPLOAD_ROUTE } from '../protocol.ts'
|
||||
import type {
|
||||
ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue,
|
||||
} from '../types.ts'
|
||||
import type { FileUploadBody, FileUploadService } from './contract.ts'
|
||||
|
||||
interface FileUploadRequest {
|
||||
/** Document-relative app-owned upload route, query string included. */
|
||||
readonly path: string
|
||||
readonly body: FileUploadBody
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
@@ -198,7 +199,7 @@ export class FileUploadRuntime extends Service implements FileUploadService {
|
||||
const query = new URLSearchParams({ sessionId })
|
||||
if (name !== undefined) query.set('name', name)
|
||||
const response = await this.post({
|
||||
path: `${FILE_UPLOAD_PATH}?${query.toString()}`,
|
||||
path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`,
|
||||
body: data,
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
@@ -230,7 +231,7 @@ function customTransport(customFetch: FileUploadFetch): FileUploadTransport {
|
||||
...(request.body instanceof ReadableStream ? { duplex: 'half' as const } : {}),
|
||||
...(request.signal === undefined ? {} : { signal: request.signal }),
|
||||
}
|
||||
const response = await customFetch(resolveUrl(request.path), init)
|
||||
const response = await customFetch(request.path, init)
|
||||
return { status: response.status, body: await response.text() }
|
||||
},
|
||||
}
|
||||
@@ -284,7 +285,8 @@ function workerTransport(): FileUploadTransport {
|
||||
}
|
||||
request.signal?.addEventListener('abort', abort, { once: true })
|
||||
const message: UploadWorkerStart = {
|
||||
url: resolveUrl(request.path).href,
|
||||
// The Worker's own base is `blob:`, so its request URL must be absolute.
|
||||
url: new URL(request.path, document.baseURI).href,
|
||||
body: request.body,
|
||||
headers: request.headers ?? {},
|
||||
}
|
||||
@@ -295,15 +297,6 @@ function workerTransport(): FileUploadTransport {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(path: string): URL {
|
||||
const pageLocation = Reflect.get(globalThis, 'location') as unknown
|
||||
const origin = typeof pageLocation === 'object' && pageLocation !== null
|
||||
&& 'origin' in pageLocation && typeof pageLocation.origin === 'string'
|
||||
? pageLocation.origin
|
||||
: undefined
|
||||
return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin)
|
||||
}
|
||||
|
||||
function parseFileUploadResult(body: string): RemoteResult<FileUploadValue> {
|
||||
const value = JSON.parse(body) as unknown
|
||||
if (!isRecord(value) || typeof value.ok !== 'boolean') {
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
/** Authenticated raw-byte route owned by the file-upload service. */
|
||||
export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'
|
||||
|
||||
/**
|
||||
* Browser-relative form of {@link FILE_UPLOAD_PATH}; see
|
||||
* .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
|
||||
*/
|
||||
export const FILE_UPLOAD_ROUTE = FILE_UPLOAD_PATH.slice(1)
|
||||
@@ -23,11 +23,12 @@ export type FileUploadReceiptId = Branded<'file-upload-receipt-id'>
|
||||
|
||||
/**
|
||||
* Fetch-shaped carrier installed by a page that owns its Host transport.
|
||||
* @param input - absolute same-origin upload URL.
|
||||
* @param input - document-relative upload route; a carrier that needs an
|
||||
* absolute URL resolves it against its own base.
|
||||
* @param init - raw request body, headers, and cancellation signal.
|
||||
* @returns the Host response.
|
||||
*/
|
||||
export type FileUploadFetch = (input: URL, init: RequestInit) => Promise<Response>
|
||||
export type FileUploadFetch = (input: string | URL, init: RequestInit) => Promise<Response>
|
||||
|
||||
/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */
|
||||
export interface ClientFileUploadHooks {
|
||||
|
||||
@@ -213,8 +213,7 @@ describe('file upload worker body', () => {
|
||||
|
||||
describe('file upload service', () => {
|
||||
it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => {
|
||||
vi.stubGlobal('location', { origin: 'https://preview.test' })
|
||||
const fetch = vi.fn((_url: URL, _init?: RequestInit) =>
|
||||
const fetch = vi.fn((_url: string | URL, _init?: RequestInit) =>
|
||||
Promise.resolve(new Response('accepted', { status: 202 })))
|
||||
;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
|
||||
const ctx = new Context()
|
||||
@@ -223,30 +222,29 @@ describe('file upload service', () => {
|
||||
const blob = new Blob(['opaque'])
|
||||
const signal = new AbortController().signal
|
||||
await expect((ctx.fileUpload as FileUploadRuntime).post({
|
||||
path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal,
|
||||
path: 'api/upload', body: blob, headers: { 'x-test': 'yes' }, signal,
|
||||
})).resolves.toEqual({ status: 202, body: 'accepted' })
|
||||
expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), {
|
||||
expect(fetch).toHaveBeenLastCalledWith('api/upload', {
|
||||
method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal,
|
||||
})
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
|
||||
await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
|
||||
expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), {
|
||||
await (ctx.fileUpload as FileUploadRuntime).post({ path: 'stream', body: stream })
|
||||
expect(fetch).toHaveBeenLastCalledWith('stream', {
|
||||
method: 'POST', body: stream, duplex: 'half',
|
||||
})
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('mounts through the plugin entry and resolves non-browser URLs', async () => {
|
||||
vi.stubGlobal('location', { origin: 'null' })
|
||||
it('mounts through the plugin entry with a document-relative route', async () => {
|
||||
const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })))
|
||||
;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber
|
||||
const body = new Blob()
|
||||
await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body })
|
||||
expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), {
|
||||
await (ctx.fileUpload as FileUploadRuntime).post({ path: 'api/session/uploadFileBinary', body })
|
||||
expect(fetch).toHaveBeenCalledWith('api/session/uploadFileBinary', {
|
||||
method: 'POST', body,
|
||||
})
|
||||
await fiber.dispose()
|
||||
@@ -257,7 +255,7 @@ describe('file upload service', () => {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(FileUploadRuntime)
|
||||
await fiber
|
||||
await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }))
|
||||
await expect((ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() }))
|
||||
.rejects.toThrow('background upload requires Web Worker support')
|
||||
await fiber.dispose()
|
||||
})
|
||||
@@ -274,19 +272,19 @@ describe('file upload service', () => {
|
||||
constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this }
|
||||
}
|
||||
vi.stubGlobal('Worker', FakeWorker)
|
||||
vi.stubGlobal('location', { origin: 'https://harness.test' })
|
||||
vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' })
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(FileUploadRuntime)
|
||||
await fiber
|
||||
const progress = vi.fn()
|
||||
const blob = new Blob(['bytes'])
|
||||
const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress })
|
||||
const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: 'api/upload', body: blob, onProgress: progress })
|
||||
const worker = FakeWorker.last
|
||||
if (worker === undefined) throw new Error('worker missing')
|
||||
expect(created).toHaveBeenCalledOnce()
|
||||
expect(revoked).toHaveBeenCalledWith('blob:worker')
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({
|
||||
url: 'https://harness.test/api/upload', body: blob, headers: {},
|
||||
url: 'https://harness.test/mount/api/upload', body: blob, headers: {},
|
||||
})
|
||||
worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent)
|
||||
worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent)
|
||||
@@ -313,11 +311,12 @@ describe('file upload service', () => {
|
||||
constructor() { FakeWorker.last = this }
|
||||
}
|
||||
vi.stubGlobal('Worker', FakeWorker)
|
||||
vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' })
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(FileUploadRuntime)
|
||||
await fiber
|
||||
const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
|
||||
const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
|
||||
const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: 'stream', body: stream })
|
||||
const worker = FakeWorker.last
|
||||
if (worker === undefined) throw new Error('worker missing')
|
||||
expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream])
|
||||
@@ -338,31 +337,32 @@ describe('file upload service', () => {
|
||||
constructor() { FakeWorker.all.push(this) }
|
||||
}
|
||||
vi.stubGlobal('Worker', FakeWorker)
|
||||
vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' })
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(FileUploadRuntime)
|
||||
await fiber
|
||||
|
||||
const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
|
||||
const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() })
|
||||
FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent)
|
||||
await expect(reported).rejects.toThrow('network failed')
|
||||
|
||||
const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
|
||||
const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() })
|
||||
FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent)
|
||||
await expect(errored).rejects.toThrow('worker crashed')
|
||||
|
||||
const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
|
||||
const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() })
|
||||
FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent)
|
||||
await expect(unnamed).rejects.toThrow('background upload worker failed')
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal })
|
||||
const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob(), signal: controller.signal })
|
||||
controller.abort()
|
||||
await expect(aborted).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce()
|
||||
|
||||
const already = new AbortController()
|
||||
already.abort()
|
||||
await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal }))
|
||||
await expect((ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob(), signal: already.signal }))
|
||||
.rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled()
|
||||
await fiber.dispose()
|
||||
@@ -390,9 +390,8 @@ describe('Session-addressed file upload', () => {
|
||||
}
|
||||
|
||||
it('assembles the scoped streaming request and parses progress and receipt fields', async () => {
|
||||
vi.stubGlobal('location', { origin: 'https://preview.test' })
|
||||
const progress = vi.fn()
|
||||
const fetch = vi.fn((_url: URL, init: RequestInit) => {
|
||||
const fetch = vi.fn((_url: string | URL, init: RequestInit) => {
|
||||
expect(init.body).toBeInstanceOf(Blob)
|
||||
progress({ loaded: 2, total: 4 })
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -416,7 +415,7 @@ describe('Session-addressed file upload', () => {
|
||||
},
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'),
|
||||
'api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
@@ -448,7 +447,6 @@ describe('Session-addressed file upload', () => {
|
||||
})
|
||||
|
||||
it('rejects malformed background results', async () => {
|
||||
vi.stubGlobal('location', { origin: 'https://preview.test' })
|
||||
;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
|
||||
fetch: () => Promise.resolve(new Response(null, { status: 413 })),
|
||||
}
|
||||
|
||||
@@ -10,19 +10,16 @@ import { useSearchableHidden } from './searchable-hidden.ts'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
/**
|
||||
* Map one authored media destination to the same-origin workspace-file URL.
|
||||
* @param protocol - `window.location.protocol` at render time.
|
||||
* @param origin - `window.location.origin` at render time.
|
||||
* @param value - The authored markdown destination, exactly as written.
|
||||
* @returns The API URL for an absolute POSIX path on an HTTP(S) page, or
|
||||
* undefined when the destination cannot be a Host-served local file
|
||||
* (non-HTTP transport such as Electron `file://`, protocol-relative or
|
||||
* relative destinations).
|
||||
* Resolve an authored POSIX image path against the document's file API.
|
||||
* @param base - canonical `document.baseURI` at render time.
|
||||
* @param value - authored markdown destination.
|
||||
* @returns an absolute HTTP(S) file-API URL, or undefined for unsupported
|
||||
* protocols and non-local paths.
|
||||
*/
|
||||
export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined {
|
||||
if (protocol !== 'http:' && protocol !== 'https:') return undefined
|
||||
if (value.length === 0 || !value.startsWith('/') || value.startsWith('//')) return undefined
|
||||
return `${origin}/api/file?path=${encodeURIComponent(value)}`
|
||||
export function localPathMediaUrl(base: string, value: string): string | undefined {
|
||||
if (!value.startsWith('/') || value.startsWith('//')) return undefined
|
||||
if (!base.startsWith('http:') && !base.startsWith('https:')) return undefined
|
||||
return new URL(`api/file?path=${encodeURIComponent(value)}`, base).href
|
||||
}
|
||||
|
||||
export interface AssistantMarkdownProps {
|
||||
@@ -50,12 +47,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
const labels = useMemo(() => markdownLabels(t), [t])
|
||||
// Local media paths in the closing prose rewrite to the same-origin file
|
||||
// API (policy re-validation lives host-side). The vocabulary identity is
|
||||
// stable per page load because MarkdownText memoizes on it.
|
||||
// MarkdownText memoizes its vocabulary; keep its identity stable across renders.
|
||||
const pathImages = useMemo<MarkdownPathImages>(() => {
|
||||
const { protocol, origin } = window.location
|
||||
return { resolve: value => localPathMediaUrl(protocol, origin, value) }
|
||||
return { resolve: value => localPathMediaUrl(document.baseURI, value) }
|
||||
}, [])
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
|
||||
@@ -14,31 +14,39 @@ function textBlock(text: string): AssistantBlock {
|
||||
return { kind: 'text', text }
|
||||
}
|
||||
|
||||
const ORIGIN = 'http://127.0.0.1:3080'
|
||||
const BASE = 'http://127.0.0.1:3080/'
|
||||
const MOUNTED_BASE = 'http://127.0.0.1:3080/tools/dsh/'
|
||||
|
||||
describe('localPathMediaUrl', () => {
|
||||
it('maps an absolute POSIX path on an HTTP page to the file API', () => {
|
||||
expect(localPathMediaUrl('http:', ORIGIN, '/tmp/graph.png'))
|
||||
.toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/graph.png')}`)
|
||||
expect(localPathMediaUrl('https:', 'https://127.0.0.1:3080', '/tmp/graph.png'))
|
||||
it('maps an absolute POSIX path on an HTTP page to the file route of its document', () => {
|
||||
expect(localPathMediaUrl(BASE, '/tmp/graph.png'))
|
||||
.toBe(`${BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`)
|
||||
expect(localPathMediaUrl('https://127.0.0.1:3080/', '/tmp/graph.png'))
|
||||
.toBe(`https://127.0.0.1:3080/api/file?path=${encodeURIComponent('/tmp/graph.png')}`)
|
||||
})
|
||||
|
||||
it('keeps the route beneath a mount the document is served from', () => {
|
||||
expect(localPathMediaUrl(MOUNTED_BASE, '/tmp/graph.png'))
|
||||
.toBe(`${MOUNTED_BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`)
|
||||
expect(localPathMediaUrl('http://127.0.0.1:3080/tools/dsh/index.html', '/tmp/graph.png'))
|
||||
.toBe(`${MOUNTED_BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`)
|
||||
})
|
||||
|
||||
it('keeps non-HTTP transports inert', () => {
|
||||
expect(localPathMediaUrl('file:', 'file:///app', '/tmp/graph.png')).toBeUndefined()
|
||||
expect(localPathMediaUrl('ws:', ORIGIN, '/tmp/graph.png')).toBeUndefined()
|
||||
expect(localPathMediaUrl('about:blank', '/tmp/graph.png')).toBeUndefined()
|
||||
expect(localPathMediaUrl('dsh-app://app/', '/tmp/graph.png')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps destinations that cannot be Host-served local files inert', () => {
|
||||
expect(localPathMediaUrl('http:', ORIGIN, '')).toBeUndefined()
|
||||
expect(localPathMediaUrl('http:', ORIGIN, '//cdn.example.com/x.png')).toBeUndefined()
|
||||
expect(localPathMediaUrl('http:', ORIGIN, 'relative.png')).toBeUndefined()
|
||||
expect(localPathMediaUrl('http:', ORIGIN, 'C:\\tmp\\x.png')).toBeUndefined()
|
||||
expect(localPathMediaUrl(BASE, '')).toBeUndefined()
|
||||
expect(localPathMediaUrl(BASE, '//cdn.example.com/x.png')).toBeUndefined()
|
||||
expect(localPathMediaUrl(BASE, 'relative.png')).toBeUndefined()
|
||||
expect(localPathMediaUrl(BASE, 'C:\\tmp\\x.png')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('encodes the full path including spaces', () => {
|
||||
expect(localPathMediaUrl('http:', ORIGIN, '/tmp/my graph.png'))
|
||||
.toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/my graph.png')}`)
|
||||
expect(localPathMediaUrl(BASE, '/tmp/my graph.png'))
|
||||
.toBe(`${BASE}api/file?path=${encodeURIComponent('/tmp/my graph.png')}`)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,18 @@ export const CHANGES_DIFF_PATH = '/api/changes.diff'
|
||||
/** Authenticated POST route for opening a changed file on the Host desktop. */
|
||||
export const CHANGES_OPEN_PATH = '/api/changes.open'
|
||||
|
||||
/**
|
||||
* Browser-relative form of {@link CHANGED_FILES_PATH}; see
|
||||
* .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
|
||||
*/
|
||||
export const CHANGED_FILES_ROUTE = CHANGED_FILES_PATH.slice(1)
|
||||
|
||||
/** Browser-relative form of {@link CHANGES_DIFF_PATH}. */
|
||||
export const CHANGES_DIFF_ROUTE = CHANGES_DIFF_PATH.slice(1)
|
||||
|
||||
/** Browser-relative form of {@link CHANGES_OPEN_PATH}. */
|
||||
export const CHANGES_OPEN_ROUTE = CHANGES_OPEN_PATH.slice(1)
|
||||
|
||||
/** Resource-address prefix of a turn's review tab in the right Sidebar. */
|
||||
export const CHANGES_REVIEW_ADDRESS = 'dsh-resource://changes-review/session/'
|
||||
|
||||
@@ -94,10 +106,10 @@ export function isChangesEvent(value: unknown): value is { turn: number } {
|
||||
* Build authenticated coordinates for the summary one `workspace/changes` event announced.
|
||||
* @param sessionId - owning Session.
|
||||
* @param seq - event sequence.
|
||||
* @returns same-origin summary URL.
|
||||
* @returns document-relative summary route.
|
||||
*/
|
||||
export function changesSummaryUrl(sessionId: SessionId, seq: number): string {
|
||||
return `${CHANGED_FILES_PATH}?${new URLSearchParams({ sessionId, seq: String(seq) })}`
|
||||
return `${CHANGED_FILES_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq) })}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,10 +117,10 @@ export function changesSummaryUrl(sessionId: SessionId, seq: number): string {
|
||||
* @param sessionId - owning Session.
|
||||
* @param seq - workspace/changes event sequence.
|
||||
* @param index - original index in the summary's files array.
|
||||
* @returns same-origin comparison URL.
|
||||
* @returns document-relative comparison route.
|
||||
*/
|
||||
export function changesDiffUrl(sessionId: SessionId, seq: number, index: number): string {
|
||||
return `${CHANGES_DIFF_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
|
||||
return `${CHANGES_DIFF_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,10 +128,10 @@ export function changesDiffUrl(sessionId: SessionId, seq: number, index: number)
|
||||
* @param sessionId - owning Session.
|
||||
* @param seq - workspace/changes event sequence.
|
||||
* @param index - original index in the summary's files array.
|
||||
* @returns same-origin action URL.
|
||||
* @returns document-relative action route.
|
||||
*/
|
||||
export function changedFileUrl(sessionId: SessionId, seq: number, index: number): string {
|
||||
return `${CHANGES_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
|
||||
return `${CHANGES_OPEN_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { changedFileUrl } from '../changes.ts'
|
||||
import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts'
|
||||
import { presentedFileUrl, PRESENT_HOST_ROUTE, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts'
|
||||
|
||||
/** State of the latest explicit open gesture for one saved file. */
|
||||
export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable'
|
||||
@@ -86,7 +86,7 @@ export class PresentedOpenController {
|
||||
private async readHost(signal: AbortSignal): Promise<void> {
|
||||
let host: PresentedHost | 'error' = 'error'
|
||||
try {
|
||||
const response = await fetch(PRESENT_HOST_PATH, { signal })
|
||||
const response = await fetch(PRESENT_HOST_ROUTE, { signal })
|
||||
if (response.ok) {
|
||||
const value: unknown = await response.json()
|
||||
if (isPresentedHost(value)) host = value
|
||||
|
||||
@@ -9,6 +9,15 @@ export const PRESENT_OPEN_PATH = '/api/present.open'
|
||||
/** Authenticated desktop availability and destination metadata. */
|
||||
export const PRESENT_HOST_PATH = '/api/present.host'
|
||||
|
||||
/**
|
||||
* Browser-relative form of {@link PRESENT_OPEN_PATH}; see
|
||||
* .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
|
||||
*/
|
||||
export const PRESENT_OPEN_ROUTE = PRESENT_OPEN_PATH.slice(1)
|
||||
|
||||
/** Browser-relative form of {@link PRESENT_HOST_PATH}. */
|
||||
export const PRESENT_HOST_ROUTE = PRESENT_HOST_PATH.slice(1)
|
||||
|
||||
/** Native file action selected by an explicit user gesture. */
|
||||
export type PresentedAction = 'open' | 'reveal'
|
||||
|
||||
@@ -49,10 +58,10 @@ export function isPresentedFile(value: unknown): value is PresentedFile {
|
||||
* @param sessionId - owning Session.
|
||||
* @param seq - deliverables/presented event sequence.
|
||||
* @param index - original index in the event's files array.
|
||||
* @returns same-origin file action URL.
|
||||
* @returns document-relative file action route.
|
||||
*/
|
||||
export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string {
|
||||
return `${PRESENT_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
|
||||
return `${PRESENT_OPEN_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,7 +77,7 @@ async function fixture() {
|
||||
describe('change summary route', () => {
|
||||
it('serves the Host-held summary without its working directory, and 404 once it is gone', async () => {
|
||||
const { read, data, summary } = await fixture()
|
||||
expect(changesSummaryUrl(SessionId('owner'), 9)).toBe(`${CHANGED_FILES_PATH}?sessionId=owner&seq=9`)
|
||||
expect(changesSummaryUrl(SessionId('owner'), 9)).toBe('api/changes.summary?sessionId=owner&seq=9')
|
||||
const response = await read()
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
@@ -95,7 +95,7 @@ describe('change summary route', () => {
|
||||
describe('change comparison route', () => {
|
||||
it('serves the Host-computed comparison, 404 once it is gone, and 500 when the read fails', async () => {
|
||||
const { compare, comparison, diff } = await fixture()
|
||||
expect(changesDiffUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_DIFF_PATH}?sessionId=owner&seq=9&index=0`)
|
||||
expect(changesDiffUrl(SessionId('owner'), 9, 0)).toBe('api/changes.diff?sessionId=owner&seq=9&index=0')
|
||||
const response = await compare()
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
@@ -132,7 +132,7 @@ describe('change comparison route', () => {
|
||||
describe('changed files native open route', () => {
|
||||
it('opens a listed file inside or outside the workspace with its verified Host path', async () => {
|
||||
const { cwd, open, opener, outside } = await fixture()
|
||||
expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9&index=0`)
|
||||
expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe('api/changes.open?sessionId=owner&seq=9&index=0')
|
||||
const response = await open()
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
|
||||
@@ -621,8 +621,8 @@ describe('ChangedFiles card', () => {
|
||||
it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => {
|
||||
const controller = new PresentedOpenController()
|
||||
controller.state.set({
|
||||
'/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening',
|
||||
'/api/changes.open?sessionId=child-session&seq=5&index=1': 'error',
|
||||
'api/changes.open?sessionId=child-session&seq=5&index=0': 'opening',
|
||||
'api/changes.open?sessionId=child-session&seq=5&index=1': 'error',
|
||||
})
|
||||
const { view } = renderCard(controller)
|
||||
// Native-open gestures belong to the review tab; the card shows counts only.
|
||||
@@ -747,31 +747,31 @@ describe('plugin registration', () => {
|
||||
expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' })
|
||||
fetcher.mockResolvedValueOnce(Response.json({ turn: 1, files: [], total: 0, added: 0, deleted: 0 }))
|
||||
await face.loadChangesSummary(SessionId('child-session'), 5)
|
||||
expect(face.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0, added: 0, deleted: 0 })
|
||||
expect(face.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0, added: 0, deleted: 0 })
|
||||
ctx.emit('connection/reset')
|
||||
expect(face.hooks.presentedHost.getSnapshot()).toBeNull()
|
||||
// The replaced connection may reach a Host that no longer serves the summaries read so far.
|
||||
expect(face.hooks.changesSummary.getSnapshot()).toEqual({})
|
||||
await face.openPresented(SessionId('child-session'), 2, 0)
|
||||
expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened')
|
||||
expect(face.hooks.presentedOpen.getSnapshot()['api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened')
|
||||
await face.openChanged(SessionId('child-session'), 5, 0)
|
||||
expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened')
|
||||
expect(face.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened')
|
||||
face.openChangesReview({ sessionId: SessionId('child-session'), seq: 5, turn: 3 }, 1)
|
||||
expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-review/session/child-session/5/3', { params: { index: 1 } })
|
||||
expect((registered as { title(address: string): string }).title('dsh-resource://changes-review/session/child-session/5/3')).toBe('Review · turn 3')
|
||||
const tabFace = tabEntry!.inject!(SessionId('child-session') as never) as unknown as ReviewInjected
|
||||
fetcher.mockResolvedValueOnce(Response.json({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }))
|
||||
await tabFace.loadChangesSummary(SessionId('child-session'), 6)
|
||||
expect(tabFace.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 })
|
||||
expect(tabFace.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 })
|
||||
fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }))
|
||||
await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1)
|
||||
expect(tabFace.hooks.changesDiff.getSnapshot()['/api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })
|
||||
expect(tabFace.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })
|
||||
expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost)
|
||||
fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' }))
|
||||
await tabFace.reloadPresentedHost()
|
||||
fetcher.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
await tabFace.openChanged(SessionId('child-session'), 5, 1)
|
||||
expect(tabFace.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=1']).toBe('opened')
|
||||
expect(tabFace.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=1']).toBe('opened')
|
||||
ctx.emit('connection/reset')
|
||||
expect(tabFace.hooks.changesDiff.getSnapshot()).toEqual({})
|
||||
// A turn that produced nothing yields no vocabulary at all.
|
||||
@@ -905,7 +905,7 @@ it('lets one delivered file span the complete row without an expansion control',
|
||||
|
||||
it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => {
|
||||
const controller = new PresentedOpenController()
|
||||
controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase })
|
||||
controller.state.set({ 'api/present.open?sessionId=session&seq=2&index=0': phase })
|
||||
const props = openProps(controller)
|
||||
const view = render(<Deliverables {...props} matched={{ changes: null, presented: [
|
||||
{ path: 'report.txt', seq: 2, index: 0 },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PresentedOpenController } from '../src/client/present-open.ts'
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
const id = SessionId('fork')
|
||||
const url = '/api/present.open?sessionId=fork&seq=2&index=1'
|
||||
const url = 'api/present.open?sessionId=fork&seq=2&index=1'
|
||||
|
||||
it('coalesces concurrent card and mention gestures, then allows another open', async () => {
|
||||
const reply = Promise.withResolvers<Response>()
|
||||
@@ -31,11 +31,11 @@ it('opens changed files through their own coordinates', async () => {
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const controller = new PresentedOpenController()
|
||||
await controller.openChanged(id, 9, 1)
|
||||
expect(fetcher.mock.calls.map(call => call[0])).toEqual(['/api/changes.open?sessionId=fork&seq=9&index=1'])
|
||||
expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened')
|
||||
expect(fetcher.mock.calls.map(call => call[0])).toEqual(['api/changes.open?sessionId=fork&seq=9&index=1'])
|
||||
expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened')
|
||||
fetcher.mockResolvedValueOnce(new Response(null, { status: 422 }))
|
||||
await controller.openChanged(id, 9, 1)
|
||||
expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable')
|
||||
expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable')
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('Presented workspace file native open route', () => {
|
||||
it('opens the source itself with current bytes and leaves it intact at disposal', async () => {
|
||||
const { cwd, open, file, fiber, opener, handler, ctx } = await fixture()
|
||||
const source = await realpath(join(cwd, file.path))
|
||||
expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`)
|
||||
expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0')
|
||||
expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404)
|
||||
for (const contents of ['current source', 'edited source']) {
|
||||
|
||||
@@ -270,11 +270,11 @@ describe('ReviewTab', () => {
|
||||
expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin'))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }))
|
||||
expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1)
|
||||
act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) })
|
||||
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) })
|
||||
expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) })
|
||||
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) })
|
||||
expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true)
|
||||
act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) })
|
||||
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) })
|
||||
expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull()
|
||||
act(() => {
|
||||
controller.state.set({})
|
||||
|
||||
@@ -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/ui-open-in-app/README.md
|
||||
README.md: d242d5a84dffbf0895485f7c79eafd5e332fe446
|
||||
README.zh.md: e954b7c71e8c4dbd1b19a041aafb7fe154631fbb
|
||||
README.md: d3de7f60390b0a1b7065bcb71025c3f3e4db19cd
|
||||
README.zh.md: b547fd144c464c01692d7a2cd47e8dda684976ac
|
||||
@@ -39,7 +39,7 @@ The main button shows the remembered application's icon — the real application
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster.
|
||||
The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现内幕——点击展开</summary>
|
||||
|
||||
插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
|
||||
插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -8,12 +8,6 @@ import {
|
||||
|
||||
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
|
||||
function hostBase(): string {
|
||||
const origin = (globalThis as { location?: { origin?: string } }).location?.origin
|
||||
return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the once-per-page availability read, the persisted last choice, and
|
||||
* the launch POST. Availability and choice publish through uSES-safe sources
|
||||
@@ -60,7 +54,7 @@ export class OpenInAppController {
|
||||
*/
|
||||
async launch(appId: string, path: string): Promise<void> {
|
||||
const body: OpenInAppOpenPayload = { app: appId, path }
|
||||
const response = await this.fetcher(new URL(OPEN_IN_APP_OPEN_ROUTE, hostBase()), {
|
||||
const response = await this.fetcher(OPEN_IN_APP_OPEN_ROUTE, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -71,7 +65,7 @@ export class OpenInAppController {
|
||||
private async run(): Promise<void> {
|
||||
let apps: readonly string[] = []
|
||||
try {
|
||||
const response = await this.fetcher(new URL(OPEN_IN_APP_APPS_ROUTE, hostBase()), {
|
||||
const response = await this.fetcher(OPEN_IN_APP_APPS_ROUTE, {
|
||||
headers: { accept: 'application/json' },
|
||||
})
|
||||
if (response.ok) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import { OPEN_IN_APP_ICON_PREFIX } from '@deepseek-ai/dsh-host-open-in-app/shared'
|
||||
import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared'
|
||||
import { OpenInAppController } from './controller.ts'
|
||||
import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx'
|
||||
import { en, NS, zh, type OpenInAppKey } from './locales.ts'
|
||||
@@ -47,7 +47,7 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
launch: (appId, path) => controller.launch(appId, path),
|
||||
choose: (appId) => { controller.choose(appId) },
|
||||
iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX}/${appId}`,
|
||||
iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`,
|
||||
}),
|
||||
}, OpenInAppAction))
|
||||
}
|
||||
@@ -58,7 +58,7 @@ describe('open-in-app browser half', () => {
|
||||
const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||
void init
|
||||
const url = String(input)
|
||||
if (url.includes('/open-in-app/apps')) {
|
||||
if (url.includes('open-in-app/apps')) {
|
||||
return new Response(JSON.stringify({ apps: ['finder', 'cursor', 7] }), { status: 200 })
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 })
|
||||
@@ -71,13 +71,13 @@ describe('open-in-app browser half', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(injected.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor'])
|
||||
})
|
||||
expect(injected.iconUrl('cursor')).toBe('/open-in-app/icon/cursor')
|
||||
expect(injected.iconUrl('cursor')).toBe('open-in-app/icon/cursor')
|
||||
|
||||
injected.choose('cursor')
|
||||
expect(injected.hooks.openInAppChoice.getSnapshot()).toBe('cursor')
|
||||
|
||||
await injected.launch('cursor', '/w/dir')
|
||||
const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('/open-in-app/open'))
|
||||
const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('open-in-app/open'))
|
||||
expect(openCall?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -88,7 +88,7 @@ describe('open-in-app browser half', () => {
|
||||
|
||||
it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => {
|
||||
if (String(input).includes('/open-in-app/apps')) throw new Error('down')
|
||||
if (String(input).includes('open-in-app/apps')) throw new Error('down')
|
||||
return new Response('', { status: 502 })
|
||||
}))
|
||||
const { ctx, fiber } = await bench()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Controller wire behavior: host-base resolution, availability filtering, and launch errors. */
|
||||
/** Controller wire behavior: document-relative routes, availability filtering, and launch errors. */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OpenInAppController } from '../src/client/controller.ts'
|
||||
@@ -36,20 +36,11 @@ describe('OpenInAppController availability', () => {
|
||||
expect(malformed.apps.getSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves routes against the page origin when the page has one', async () => {
|
||||
vi.stubGlobal('location', { origin: 'http://dsh.example:8080' })
|
||||
it('requests the document-relative availability route', async () => {
|
||||
const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) })
|
||||
const controller = new OpenInAppController(fetcher)
|
||||
await controller.load()
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.example:8080/open-in-app/apps')
|
||||
})
|
||||
|
||||
it('falls back to the internal host base under a null origin', async () => {
|
||||
vi.stubGlobal('location', { origin: 'null' })
|
||||
const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) })
|
||||
const controller = new OpenInAppController(fetcher)
|
||||
await controller.load()
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.internal/open-in-app/apps')
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/apps')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,6 +63,7 @@ describe('OpenInAppController launching', () => {
|
||||
const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void input; void init; return jsonResponse({ ok: true }) })
|
||||
const controller = new OpenInAppController(fetcher)
|
||||
await controller.launch('cursor', '/w/dir')
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/open')
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
|
||||
@@ -55,7 +55,7 @@ function bench(over: {
|
||||
useOpenInAppChoice: useSelector(choice),
|
||||
launch,
|
||||
choose,
|
||||
iconUrl: (appId: string) => `/open-in-app/icon/${appId}`,
|
||||
iconUrl: (appId: string) => `open-in-app/icon/${appId}`,
|
||||
t,
|
||||
} as unknown as OpenInAppActionProps
|
||||
return { props, launch, choose }
|
||||
@@ -234,7 +234,7 @@ describe('OpenInAppAction launching', () => {
|
||||
const b = bench({ apps: ['terminal'], cwd: '/w/dir' })
|
||||
const { container } = render(<OpenInAppAction {...b.props} />)
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.getAttribute('src')).toBe('/open-in-app/icon/terminal')
|
||||
expect(img?.getAttribute('src')).toBe('open-in-app/icon/terminal')
|
||||
if (img !== null) fireEvent.error(img)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import { extractAppIcon, type OpenInAppIcon } from './icons.ts'
|
||||
import { internals } from './internals.ts'
|
||||
import {
|
||||
OPEN_IN_APP_APPS_ROUTE, OPEN_IN_APP_ICON_PREFIX, OPEN_IN_APP_OPEN_ROUTE,
|
||||
OPEN_IN_APP_APPS_PATH, OPEN_IN_APP_ICON_PREFIX_PATH, OPEN_IN_APP_OPEN_PATH,
|
||||
} from './shared.ts'
|
||||
|
||||
export type * from './shared.ts'
|
||||
@@ -192,7 +192,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: OPEN_IN_APP_APPS_ROUTE,
|
||||
path: OPEN_IN_APP_APPS_PATH,
|
||||
handler: async (req, res) => {
|
||||
if (rejected(req, res)) return
|
||||
if (req.method !== 'GET') {
|
||||
@@ -201,11 +201,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
sendJson(res, 200, { apps: [...(await availability()).keys()] })
|
||||
},
|
||||
}), `open-in-app: GET ${OPEN_IN_APP_APPS_ROUTE}`)
|
||||
}), `open-in-app: GET ${OPEN_IN_APP_APPS_PATH}`)
|
||||
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: OPEN_IN_APP_ICON_PREFIX,
|
||||
path: OPEN_IN_APP_ICON_PREFIX_PATH,
|
||||
handler: async (req, res) => {
|
||||
if (rejected(req, res)) return
|
||||
if (req.method !== 'GET') {
|
||||
@@ -214,7 +214,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
// Node always sets url on server requests; String keeps that fact local.
|
||||
const pathname = new URL(String(req.url), 'http://localhost').pathname
|
||||
const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX.length).replace(/^\//, '')
|
||||
const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX_PATH.length).replace(/^\//, '')
|
||||
const noIcon = (): void => { sendJson(res, 404, { code: 'not-found', message: `no icon for ${id}` }) }
|
||||
const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id)
|
||||
if (app === undefined) {
|
||||
@@ -236,11 +236,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
res.setHeader('cache-control', 'public, max-age=3600')
|
||||
res.end(icon.bytes)
|
||||
},
|
||||
}), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX}/<id>`)
|
||||
}), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX_PATH}/<id>`)
|
||||
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: OPEN_IN_APP_OPEN_ROUTE,
|
||||
path: OPEN_IN_APP_OPEN_PATH,
|
||||
handler: async (req, res) => {
|
||||
if (rejected(req, res)) return
|
||||
if (req.method !== 'POST') {
|
||||
@@ -307,5 +307,5 @@ export function apply(ctx: Context, config: Config): void {
|
||||
sendJson(res, 502, { code: 'launch-failed', message: `failed to launch ${app.id}` })
|
||||
}
|
||||
},
|
||||
}), `open-in-app: POST ${OPEN_IN_APP_OPEN_ROUTE}`)
|
||||
}), `open-in-app: POST ${OPEN_IN_APP_OPEN_PATH}`)
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
/**
|
||||
* Route paths and wire payloads shared verbatim by the host routes and the
|
||||
* browser package (`@deepseek-ai/dsh-client-ui-open-in-app`), published as
|
||||
* the `./shared` subpath. Browser-safe: constants and types only.
|
||||
* the `./shared` subpath. Browser-safe: constants and types only. Each route
|
||||
* carries the absolute pathname the Host registers beside the
|
||||
* document-relative form the browser addresses; see
|
||||
* .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
|
||||
*/
|
||||
|
||||
/** GET route serving the probed application ids. */
|
||||
export const OPEN_IN_APP_APPS_ROUTE = '/open-in-app/apps'
|
||||
/** GET route path serving the probed application ids. */
|
||||
export const OPEN_IN_APP_APPS_PATH = '/open-in-app/apps'
|
||||
|
||||
/** GET prefix serving one PNG bundle icon per application id. */
|
||||
export const OPEN_IN_APP_ICON_PREFIX = '/open-in-app/icon'
|
||||
/** Browser-relative form of {@link OPEN_IN_APP_APPS_PATH}. */
|
||||
export const OPEN_IN_APP_APPS_ROUTE = OPEN_IN_APP_APPS_PATH.slice(1)
|
||||
|
||||
/** POST route launching one application on one workspace directory. */
|
||||
export const OPEN_IN_APP_OPEN_ROUTE = '/open-in-app/open'
|
||||
/** GET prefix path serving one PNG bundle icon per application id. */
|
||||
export const OPEN_IN_APP_ICON_PREFIX_PATH = '/open-in-app/icon'
|
||||
|
||||
/** Browser-relative form of {@link OPEN_IN_APP_ICON_PREFIX_PATH}. */
|
||||
export const OPEN_IN_APP_ICON_PREFIX_ROUTE = OPEN_IN_APP_ICON_PREFIX_PATH.slice(1)
|
||||
|
||||
/** POST route path launching one application on one workspace directory. */
|
||||
export const OPEN_IN_APP_OPEN_PATH = '/open-in-app/open'
|
||||
|
||||
/** Browser-relative form of {@link OPEN_IN_APP_OPEN_PATH}. */
|
||||
export const OPEN_IN_APP_OPEN_ROUTE = OPEN_IN_APP_OPEN_PATH.slice(1)
|
||||
|
||||
/** Apps-route response: catalog ids probed as installed, in menu order. */
|
||||
export interface OpenInAppAppsPayload {
|
||||
|
||||
@@ -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/session-query/session-log-export/README.md
|
||||
README.md: 8697b0c2c105c928f5325f953b7fb663ae68e18e
|
||||
README.zh.md: fe60c6ab8cc8d2d5e7515416bb72624584c11b9d
|
||||
README.md: 165f0a8ecdd268d33a3cb1b8c0d506e12791f6d6
|
||||
README.zh.md: 3602f9aa1b4cb403498132fb1247200b3bc7e341
|
||||
@@ -50,7 +50,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/export` | Records a human-command lifecycle; the submitting browser downloads `GET /api/session.export?sessionId=<id>&includeDescendants=true` |
|
||||
| `/export` | Records a human-command lifecycle; the submitting browser downloads the document-relative `api/session.export?sessionId=<id>&includeDescendants=true` (Host route `/api/session.export`) |
|
||||
| `/export <path>` | An error; browser downloads choose their destination through the browser's ordinary download behavior |
|
||||
|
||||
### What to expect
|
||||
@@ -77,7 +77,7 @@ The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) regis
|
||||
|
||||
### Download flow
|
||||
|
||||
Both entry paths issue a `HEAD` preflight to `/api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session.
|
||||
Both entry paths issue a `HEAD` preflight to the document-relative `api/session.export?...`, then hand the GET route to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session.
|
||||
|
||||
The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
|
||||
|
||||
| 输入 | 结果 |
|
||||
|---|---|
|
||||
| `/export` | 记录用户命令的生命周期;提交命令的浏览器下载 `GET /api/session.export?sessionId=<id>&includeDescendants=true` |
|
||||
| `/export` | 记录用户命令的生命周期;提交命令的浏览器下载文档相对的 `api/session.export?sessionId=<id>&includeDescendants=true`(Host 路由 `/api/session.export`) |
|
||||
| `/export <path>` | 错误;浏览器下载通过浏览器的普通下载行为选择目标位置 |
|
||||
|
||||
### 预期行为
|
||||
@@ -77,7 +77,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
|
||||
|
||||
### 下载流程
|
||||
|
||||
两条入口都会先向 `/api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。
|
||||
两条入口都会先向文档相对的 `api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET 路由交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。
|
||||
|
||||
Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { SESSION_LOG_EXPORT_ROUTE } from '../routes.ts'
|
||||
|
||||
/** Download phases presented by the shared modal. */
|
||||
export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error'
|
||||
@@ -18,6 +19,7 @@ export interface SessionLogDownloadState {
|
||||
bySession: Record<string, SessionLogDownloadEntry | undefined>
|
||||
}
|
||||
|
||||
/** HTTP carrier for the export route. */
|
||||
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
|
||||
type Save = (url: string, filename: string) => void
|
||||
|
||||
@@ -33,8 +35,9 @@ export function sessionLogZipFilename(sessionId: SessionId): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a Host download URL to the browser download manager.
|
||||
* @param url - same-origin Host download URL.
|
||||
* Hand a Host download route to the browser download manager, which resolves it
|
||||
* against the document's own base.
|
||||
* @param url - document-relative Host download route.
|
||||
* @param filename - browser download filename.
|
||||
*/
|
||||
export function downloadUrl(url: string, filename: string): void {
|
||||
@@ -44,12 +47,6 @@ export function downloadUrl(url: string, filename: string): void {
|
||||
anchor.click()
|
||||
}
|
||||
|
||||
/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
|
||||
function hostBase(): string {
|
||||
const origin = (globalThis as { location?: { origin?: string } }).location?.origin
|
||||
return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -112,15 +109,14 @@ export class SessionLogDownloadController {
|
||||
private async run(sessionId: SessionId, signal: AbortSignal): Promise<void> {
|
||||
this.publish(sessionId, { open: true, status: 'downloading', error: null })
|
||||
try {
|
||||
const url = new URL('/api/session.export', hostBase())
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
url.searchParams.set('includeDescendants', 'true')
|
||||
const response = await this.fetcher(url, { method: 'HEAD', signal })
|
||||
const query = new URLSearchParams({ sessionId, includeDescendants: 'true' })
|
||||
const route = `${SESSION_LOG_EXPORT_ROUTE}?${query.toString()}`
|
||||
const response = await this.fetcher(route, { method: 'HEAD', signal })
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
|
||||
}
|
||||
this.save(url.toString(), sessionLogZipFilename(sessionId))
|
||||
this.save(route, sessionLogZipFilename(sessionId))
|
||||
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
|
||||
this.publish(sessionId, { open, status: 'success', error: null })
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type SessionLogCompressionLevel,
|
||||
type SessionLogExportReady,
|
||||
} from './archive.ts'
|
||||
import { SESSION_LOG_EXPORT_PATH } from './routes.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||
@@ -39,8 +40,7 @@ export type {
|
||||
export const name = 'session-log-download'
|
||||
export const inject = ['commands', 'connection']
|
||||
|
||||
/** Stable browser download path retained across the transport migration. */
|
||||
export const SESSION_LOG_EXPORT_PATH = '/api/session.export'
|
||||
export { SESSION_LOG_EXPORT_PATH } from './routes.ts'
|
||||
|
||||
/** Session-log archive policy. */
|
||||
export interface Config {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The absolute pathname the Host registers the export under and the
|
||||
* document-relative form the browser addresses; see
|
||||
* .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
|
||||
*/
|
||||
|
||||
/** Absolute registration path for the ZIP download route. */
|
||||
export const SESSION_LOG_EXPORT_PATH = '/api/session.export'
|
||||
|
||||
/** Browser-relative form of {@link SESSION_LOG_EXPORT_PATH}. */
|
||||
export const SESSION_LOG_EXPORT_ROUTE = SESSION_LOG_EXPORT_PATH.slice(1)
|
||||
@@ -21,14 +21,12 @@ describe('SessionLogDownloadController', () => {
|
||||
await controller.download(SID)
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
const [route, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(route).toBe(`api/session.export?sessionId=${SID}&includeDescendants=true`)
|
||||
expect(init.method).toBe('HEAD')
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
url.toString(),
|
||||
route,
|
||||
'dsh-session-session-export-controller.zip',
|
||||
)
|
||||
expect(controller.store.getSnapshot().bySession[SID]).toEqual({
|
||||
@@ -99,8 +97,7 @@ describe('SessionLogDownloadController', () => {
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('uses the null-origin fallback and default browser operations', async () => {
|
||||
vi.stubGlobal('location', { origin: 'null' })
|
||||
it('requests the document-relative route through the default carrier', async () => {
|
||||
const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
@@ -108,7 +105,7 @@ describe('SessionLogDownloadController', () => {
|
||||
|
||||
await controller.download(SID)
|
||||
|
||||
expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal')
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.export?sessionId=${SID}&includeDescendants=true`)
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' })
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -133,14 +130,14 @@ describe('SessionLogDownloadController', () => {
|
||||
})
|
||||
|
||||
describe('browser download helpers', () => {
|
||||
it('sanitizes the archive filename and hands the URL to a download anchor', () => {
|
||||
it('sanitizes the archive filename and hands the relative route to a download anchor', () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip')
|
||||
downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip')
|
||||
downloadUrl('api/session.export?sessionId=a', 'archive.zip')
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
const anchor = click.mock.instances[0] as HTMLAnchorElement
|
||||
expect(anchor.href).toBe('http://host/api/session.export?sessionId=a')
|
||||
expect(anchor.getAttribute('href')).toBe('api/session.export?sessionId=a')
|
||||
expect(anchor.download).toBe('archive.zip')
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@
|
||||
"src/client/controller.ts",
|
||||
"src/client/index.ts",
|
||||
"src/client/locales.ts",
|
||||
"src/routes.ts",
|
||||
"src/css-modules.d.ts"
|
||||
],
|
||||
"references": [
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
},
|
||||
"files": [
|
||||
"src/archive.ts",
|
||||
"src/index.ts"
|
||||
"src/index.ts",
|
||||
"src/routes.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
|
||||
@@ -260,9 +260,14 @@ describe('gate graph validation', () => {
|
||||
expect(ids).toEqual([
|
||||
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints',
|
||||
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
|
||||
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config',
|
||||
'runtime-closure',
|
||||
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
|
||||
'cordis-config', 'runtime-closure',
|
||||
])
|
||||
})
|
||||
|
||||
it('caps the local hygiene aggregate at four workers', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
|
||||
|
||||
expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({
|
||||
workers: 4,
|
||||
source: '8 available CPU(s), hygiene cap 4',
|
||||
@@ -344,6 +349,15 @@ describe('gate graph validation', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'keeps browser app-route resolution enforcement in %s',
|
||||
(mode) => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('client-route-resolution')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'keeps application entrypoint enforcement in %s',
|
||||
(mode) => {
|
||||
|
||||
+10
-8
@@ -304,14 +304,21 @@ function ciSharedStaticGates(): Gate[] {
|
||||
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
...sharedHygieneGates(),
|
||||
pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
]
|
||||
}
|
||||
|
||||
function sharedHygieneGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
|
||||
label: 'optional dependency imports',
|
||||
}),
|
||||
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
|
||||
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
|
||||
pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }),
|
||||
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
|
||||
pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -711,12 +718,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
label: 'node-next types',
|
||||
...artifactOptions,
|
||||
}),
|
||||
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
|
||||
label: 'optional dependency imports',
|
||||
}),
|
||||
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
|
||||
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
|
||||
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
|
||||
...sharedHygieneGates(),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+40
-17
@@ -29,31 +29,54 @@ export const repositoryConfigHost: ts.ParseConfigFileHost = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one face aggregate tsconfig and flatten all referenced projects into one
|
||||
* semantic graph. Never seed the root solution: flattening host+client into one
|
||||
* program collides the cordis Context merges.
|
||||
*/
|
||||
function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph {
|
||||
const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`)
|
||||
const rootConfig = parseConfig(rootConfigPath)
|
||||
const rootNames = new Set<string>()
|
||||
const visited = new Set<string>()
|
||||
/** One compiler face aggregate: its own config and every project it compiles. */
|
||||
export interface FaceConfigs {
|
||||
/** The aggregate's `tsconfig.<face>.json`. */
|
||||
readonly root: ts.ParsedCommandLine
|
||||
/** Every config the face compiles, its own aggregate included, keyed by resolved config path. */
|
||||
readonly byPath: ReadonlyMap<string, ts.ParsedCommandLine>
|
||||
}
|
||||
|
||||
const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
|
||||
if (visited.has(configPath)) return
|
||||
visited.add(configPath)
|
||||
for (const fileName of parsed.fileNames) rootNames.add(fileName)
|
||||
/**
|
||||
* Parse one compiler face aggregate and every project it references. Never seed
|
||||
* the root solution: the two faces are separate graphs, and flattening
|
||||
* host+client into one program collides the cordis Context merges.
|
||||
* @param projectRoot - repository root holding `tsconfig.<face>.json`.
|
||||
* @param face - which aggregate to parse.
|
||||
* @returns the aggregate's root config and every config it compiles.
|
||||
*/
|
||||
export function faceConfigs(projectRoot: string, face: CompilerFace): FaceConfigs {
|
||||
const rootPath = resolve(projectRoot, `tsconfig.${face}.json`)
|
||||
const root = parseConfig(rootPath)
|
||||
const byPath = new Map<string, ts.ParsedCommandLine>([[rootPath, root]])
|
||||
const collect = (parsed: ts.ParsedCommandLine): void => {
|
||||
for (const reference of parsed.projectReferences ?? []) {
|
||||
const referencePath = ts.resolveProjectReferencePath(reference)
|
||||
collect(referencePath, parseConfig(referencePath))
|
||||
if (byPath.has(referencePath)) continue
|
||||
const referenced = parseConfig(referencePath)
|
||||
byPath.set(referencePath, referenced)
|
||||
collect(referenced)
|
||||
}
|
||||
}
|
||||
collect(rootConfigPath, rootConfig)
|
||||
collect(root)
|
||||
return { root, byPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten one compiler face aggregate into one semantic graph.
|
||||
* @param projectRoot - repository root holding `tsconfig.<face>.json`.
|
||||
* @param face - which aggregate to flatten.
|
||||
* @returns every root name the face compiles, with the aggregate's options.
|
||||
*/
|
||||
function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph {
|
||||
const { root, byPath } = faceConfigs(projectRoot, face)
|
||||
const rootNames = new Set<string>()
|
||||
for (const parsed of byPath.values()) {
|
||||
for (const fileName of parsed.fileNames) rootNames.add(fileName)
|
||||
}
|
||||
return {
|
||||
rootNames: [...rootNames],
|
||||
options: rootConfig.options,
|
||||
options: root.options,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/** Regression coverage for the browser app-route guard. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { browserFaceSources, findRouteResolutionViolations, type RouteGateFace } from './verify-client-route-resolution.ts'
|
||||
|
||||
function patterns(file: string, source: string, face?: RouteGateFace): string[] {
|
||||
return findRouteResolutionViolations(file, source, face).map(violation => violation.pattern)
|
||||
}
|
||||
|
||||
function rules(file: string, source: string, face?: RouteGateFace): string[] {
|
||||
return findRouteResolutionViolations(file, source, face).map(violation => violation.rule)
|
||||
}
|
||||
|
||||
const CLIENT_FILE = 'packages/client/ui-example/src/client/View.tsx'
|
||||
const PRODUCER_FILE = 'packages/client/modules/src/index.ts'
|
||||
|
||||
describe('browser app-route guard', () => {
|
||||
it('accepts document-relative request targets and routes that are not targets', () => {
|
||||
expect(patterns(CLIENT_FILE, `
|
||||
export const PRESENT_OPEN_PATH = '/api/present.open'
|
||||
const CHANNEL = '/api'
|
||||
export async function load(id: string) {
|
||||
const bundle = 'plugins/local.js'
|
||||
const icon = \`open-in-app/icon/\${id}\`
|
||||
const file = new URL(\`api/file?path=\${id}\`, document.baseURI)
|
||||
await customFetch('api/session.export?sessionId=s1')
|
||||
await connection.rpc.call('/api', 'goals/create', {})
|
||||
webserver.register({ path: '/plugins' })
|
||||
const identity = window.location.hostname
|
||||
const foreign = 'https://cdn.example/app.js'
|
||||
const asset = '/assets/app.js'
|
||||
return [bundle, icon, file.href, CHANNEL, identity, foreign, asset]
|
||||
}
|
||||
`)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects every request target that binds a route to one mount', () => {
|
||||
expect(findRouteResolutionViolations(CLIENT_FILE, `
|
||||
export async function load(id: string) {
|
||||
const origin = window.location.origin
|
||||
await fetch('/api/remote.mux')
|
||||
await fetch(\`/plugins/\${id}/client.js\`)
|
||||
await fetch(\`\${origin}/api/file?path=\${id}\`)
|
||||
await this.fetcher('/api/custom')
|
||||
new EventSource('/plugins/events')
|
||||
new WebSocket('wss://harness.example/api/remote.mux')
|
||||
document.querySelector('script').src = '//harness.example/api/file'
|
||||
return id
|
||||
}
|
||||
`).map(violation => violation.line)).toEqual([4, 5, 6, 7, 8, 9, 10])
|
||||
expect(rules(CLIENT_FILE, `
|
||||
await fetch('/api/remote.mux')
|
||||
await fetch(\`\${window.location.origin}/api/file\`)
|
||||
`)).toEqual(['request-target', 'request-target'])
|
||||
})
|
||||
|
||||
it('rejects a root-absolute app route in a JSX resource attribute', () => {
|
||||
expect(findRouteResolutionViolations(CLIENT_FILE, `
|
||||
export function View({ id }: { id: string }) {
|
||||
return <div>
|
||||
<img src="/api/file?path=x" />
|
||||
<img src={\`\${window.location.origin}/api/file?path=\${id}\`} />
|
||||
<img src={\`/plugins/\${id}/preview.png\`} />
|
||||
<a href="open-in-app/apps">apps</a>
|
||||
<a href="https://cdn.example/app.js">cdn</a>
|
||||
</div>
|
||||
}
|
||||
`).map(violation => violation.line)).toEqual([4, 5, 6])
|
||||
})
|
||||
|
||||
it('rejects a shared host route key used without stripping its leading slash', () => {
|
||||
const source = `
|
||||
import { PRESENT_OPEN_PATH, EVENTS_ENDPOINT } from '../presented.ts'
|
||||
await fetch(PRESENT_OPEN_PATH)
|
||||
await fetch(\`\${EVENTS_ENDPOINT}?since=1\`)
|
||||
await fetch(PRESENT_OPEN_PATH.slice(1))
|
||||
`
|
||||
expect(findRouteResolutionViolations(CLIENT_FILE, source).map(violation => violation.line)).toEqual([3, 4])
|
||||
expect(rules(CLIENT_FILE, source)).toEqual(['host-route-key', 'host-route-key'])
|
||||
})
|
||||
|
||||
it('rejects a relative app route resolved against a location read', () => {
|
||||
expect(findRouteResolutionViolations(CLIENT_FILE, `
|
||||
const url = new URL('api/session.export?sessionId=s1', window.location.origin)
|
||||
const ok = new URL('api/session.export?sessionId=s1', document.baseURI)
|
||||
const mapped = new URL(input, globalThis.location.origin)
|
||||
return [url, ok, mapped]
|
||||
`)).toEqual([
|
||||
expect.objectContaining({ line: 2, rule: 'location-base' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves identity and transport reads of location alone', () => {
|
||||
expect(patterns(CLIENT_FILE, `
|
||||
const trusted = window.location.hostname === 'localhost'
|
||||
const requested = new URL(location.href).searchParams.get('source')
|
||||
const { origin } = document.location
|
||||
return [trusted, requested, origin]
|
||||
`)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser-reference producers', () => {
|
||||
it('rejects a root-absolute app route stamped into a field the browser resolves', () => {
|
||||
expect(findRouteResolutionViolations(PRODUCER_FILE, `
|
||||
const bootstrap = { phase: 'bootstrap', url: \`/plugins/??\${resources}&rev=\${rev}\`, rev, entries }
|
||||
const row = { id, url: 'plugins/boot.js', initialUrl: '/plugins/all.js', rev }
|
||||
const map = { src: 'https://harness.example/plugins/??a/client.js.map&rev=r' }
|
||||
const link = { href: '/open-in-app/apps' }
|
||||
const script = { src: '//harness.example/plugins/a.js' }
|
||||
const key = \`/plugins/\${comboSearch(ids, rev)}\`
|
||||
return [bootstrap, row, map, link, script, key]
|
||||
`, 'reference-producer').map(violation => violation.rule)).toEqual(Array<string>(5).fill('reference-producer'))
|
||||
})
|
||||
|
||||
it('leaves internal route keys and generated source names alone', () => {
|
||||
expect(patterns(PRODUCER_FILE, `
|
||||
const PLUGIN_ROUTE = '/plugins'
|
||||
const key = \`\${PLUGIN_ROUTE}/\${comboSearch(ids, rev)}\`
|
||||
const section = { map: { sources: [\`/plugins/\${record.entry.id}/client.js\`] } }
|
||||
return [key, section]
|
||||
`, 'reference-producer')).toEqual([])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
/**
|
||||
* Write one fixture project: its config, its source files, and its declared
|
||||
* libraries. The `src` tree the discovery anchors on always exists.
|
||||
*/
|
||||
function writeProject(root: string, name: string, options: {
|
||||
lib: readonly string[]
|
||||
/** Project-relative source paths to write. */
|
||||
sources: readonly string[]
|
||||
/** Config `include` patterns; defaults to the `src` tree. */
|
||||
include?: readonly string[]
|
||||
}): void {
|
||||
const dir = join(root, name)
|
||||
mkdirSync(join(dir, 'src'), { recursive: true })
|
||||
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
||||
compilerOptions: { lib: options.lib, noEmit: true },
|
||||
include: [...options.include ?? ['src']],
|
||||
}))
|
||||
for (const source of options.sources) {
|
||||
mkdirSync(dirname(join(dir, source)), { recursive: true })
|
||||
writeFileSync(join(dir, source), 'export {}\n')
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the two face aggregates of one fixture root over the named projects. */
|
||||
function writeFaceAggregates(root: string, client: readonly string[], host: readonly string[]): void {
|
||||
const references = (names: readonly string[]): { path: string }[] => names.map(name => ({ path: `./${name}` }))
|
||||
writeFileSync(join(root, 'tsconfig.client.json'), JSON.stringify({ files: [], references: references(client) }))
|
||||
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ files: [], references: references(host) }))
|
||||
}
|
||||
|
||||
describe('browser face discovery', () => {
|
||||
it('takes sources from the Client aggregate alone', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-'))
|
||||
try {
|
||||
writeProject(root, 'browser-package', { lib: ['ES2024', 'DOM'], sources: ['src/view.ts', 'src/globals.d.ts'] })
|
||||
writeProject(root, 'shared-package', { lib: ['ES2024', 'DOM'], sources: ['src/both-faces.ts'] })
|
||||
writeProject(root, 'host-package', { lib: ['ES2024'], sources: ['src/host.ts'] })
|
||||
writeFaceAggregates(root, ['browser-package', 'shared-package', 'host-package'], ['shared-package', 'host-package'])
|
||||
|
||||
// DOM libraries alone do not make a browser face: a project the Host
|
||||
// aggregate also compiles has a host half, and a project without them is
|
||||
// host-side. Declarations carry no request target and stay out.
|
||||
expect(browserFaceSources(root)).toEqual(['browser-package/src/view.ts'])
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud when a DOM client project compiles nothing from its source tree', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-moved-'))
|
||||
try {
|
||||
// The DOM face still has a `src` directory, but its config now compiles a
|
||||
// tree the discovery does not know: scanning less must not pass quietly.
|
||||
writeProject(root, 'browser-package', { lib: ['DOM'], sources: ['lib/moved.ts'], include: ['lib'] })
|
||||
writeProject(root, 'host-package', { lib: ['ES2024'], sources: ['src/host.ts'] })
|
||||
writeFaceAggregates(root, ['browser-package'], ['host-package'])
|
||||
|
||||
expect(() => browserFaceSources(root)).toThrow(/contributes no source file/u)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Browser-face route gate: an app-owned route reachable from browser code is
|
||||
* document-relative. Only request targets are governed: a root-absolute
|
||||
* (`/api/x`), protocol-relative (`//host/api/x`) or absolute
|
||||
* (`https://host/api/x`) app route used to address a resource binds the bundle
|
||||
* to one mount, and a relative app route resolved against a `location` read
|
||||
* rebuilds the base the served document already provides. A request target is
|
||||
* the first argument of a request constructor, of a `fetch`/`fetcher` callee,
|
||||
* or of a dynamic import; an assigned resource property (`script.src = ...`);
|
||||
* or a JSX `src`/`href` attribute.
|
||||
*
|
||||
* Route keys stay absolute pathnames — the RPC channel key, the path a route is
|
||||
* registered under, and the shared `*_PATH`/`*_ENDPOINT` constants carrying
|
||||
* them — so a literal that is not a request target is out of scope. A browser
|
||||
* half addresses such a key by stripping its leading slash, which the shared-key
|
||||
* rule below requires.
|
||||
*
|
||||
* The static producers of browser references are checked in the same pass: a
|
||||
* field the browser resolves against its document (`url`, `src`, `href`)
|
||||
* must not be stamped with a root-absolute app route. Internal route keys and
|
||||
* response-table keys stay absolute and are not references. This rule reads
|
||||
* inline literal fragments only, so a composed reference
|
||||
* (`comboReference(...)`, `artifact.url.slice(1)`) is not traced:
|
||||
* `packages/client/modules/tests/node-half.client.spec.ts` owns the regression
|
||||
* signal for composed graph rows, batch descriptors, and the source-map trailer.
|
||||
*
|
||||
* Rationale: .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { faceConfigs } from './ts-project.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const GATE = 'verify-client-route-resolution'
|
||||
/** App-owned route prefixes; a foreign path (`/assets/x`) is not governed here. */
|
||||
const APP_ROUTE_PREFIX = '(?:api|plugins|open-in-app)(?:/|$)'
|
||||
/** A root-absolute or protocol-relative app route literal. */
|
||||
const ROOT_ABSOLUTE_ROUTE = new RegExp(`^/{1,2}${APP_ROUTE_PREFIX}`)
|
||||
/** An absolute or protocol-relative app route literal on any origin or scheme. */
|
||||
const ABSOLUTE_ROUTE = new RegExp(`^(?:[A-Za-z][A-Za-z\\d+.-]*:)?//[^/]+/${APP_ROUTE_PREFIX}`)
|
||||
/** A relative app route reference (`api/x`, `./plugins/x`). */
|
||||
const RELATIVE_APP_ROUTE = new RegExp(`^(?:\\./)*${APP_ROUTE_PREFIX}`)
|
||||
/** A shared host route key: the absolute pathname a route is registered under. */
|
||||
const HOST_ROUTE_KEY = /(?:^|_)(?:PATH|ENDPOINT)$/
|
||||
|
||||
/** Request constructors whose first argument addresses a resource. */
|
||||
const REQUEST_CONSTRUCTORS: Record<string, true> = { Request: true, EventSource: true, WebSocket: true, URL: true }
|
||||
/** Assigned properties and JSX attributes that address a resource. */
|
||||
const REQUEST_PROPERTIES: Record<string, true> = { href: true, src: true }
|
||||
/** Fields a browser resolves against its own document. */
|
||||
const REFERENCE_FIELDS: Record<string, true> = { url: true, src: true, href: true, initialUrl: true }
|
||||
|
||||
/**
|
||||
* Static producer of browser references: the node half composes the boot graph
|
||||
* the browser resolves against its document. No request-target position in
|
||||
* browser code sees those values, so its reference fields are checked directly.
|
||||
*/
|
||||
const REFERENCE_PRODUCERS = [
|
||||
'packages/client/modules/src/index.ts',
|
||||
]
|
||||
|
||||
/** Rule that produced a violation, for diagnostics and for the gate's own coverage. */
|
||||
export type RouteResolutionRule = 'request-target' | 'location-base' | 'host-route-key' | 'reference-producer'
|
||||
|
||||
/** What a scanned file is: browser face, or a static producer of browser references. */
|
||||
export type RouteGateFace = 'browser' | 'reference-producer'
|
||||
|
||||
const HINT: Record<RouteResolutionRule, string> = {
|
||||
'request-target': 'write the route document-relative (api/x)',
|
||||
'location-base': 'resolve the route against document.baseURI, not against a location read',
|
||||
'host-route-key': 'strip the leading slash before the browser uses this key (KEY.slice(1))',
|
||||
'reference-producer': 'emit the browser reference document-relative (plugins/x)',
|
||||
}
|
||||
|
||||
/** One app-owned route reached from a place that binds it to one mount. */
|
||||
export interface RouteResolutionViolation {
|
||||
/** One-based source column. */
|
||||
column: number
|
||||
/** Repository-relative source path. */
|
||||
file: string
|
||||
/** One-based source line. */
|
||||
line: number
|
||||
/** The offending expression, as written. */
|
||||
pattern: string
|
||||
/** Rule that recognized the route. */
|
||||
rule: RouteResolutionRule
|
||||
}
|
||||
|
||||
/** A location expression: `location`, optionally behind its owning global. */
|
||||
const LOCATION_EXPRESSION = /^(?:(?:window|self|globalThis|document|top|parent|frames)\.)?(?:defaultView\.)?location$/
|
||||
|
||||
/** Source text of a location expression, without whitespace. */
|
||||
function locationText(node: ts.Expression): string | undefined {
|
||||
const text = node.getText().replace(/\s+/g, '')
|
||||
return LOCATION_EXPRESSION.test(text) ? text : undefined
|
||||
}
|
||||
|
||||
/** Whether an expression rebuilds a base from the page location anywhere inside it. */
|
||||
function readsLocation(node: ts.Node): boolean {
|
||||
if (ts.isExpression(node) && locationText(node) !== undefined) return true
|
||||
return node.forEachChild(readsLocation) === true
|
||||
}
|
||||
|
||||
/** Host route keys an expression references without stripping their leading slash. */
|
||||
function unstrippedHostRouteKeys(node: ts.Node): ts.Identifier[] {
|
||||
if (ts.isIdentifier(node)) {
|
||||
if (!HOST_ROUTE_KEY.test(node.text)) return []
|
||||
const derived = ts.isPropertyAccessExpression(node.parent) && node.parent.name.text === 'slice'
|
||||
return derived ? [] : [node]
|
||||
}
|
||||
const keys: ts.Identifier[] = []
|
||||
node.forEachChild((child) => { keys.push(...unstrippedHostRouteKeys(child)) })
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Callee name of a call or construction, for constructors and `fetch`-shaped carriers. */
|
||||
function calleeName(expression: ts.Expression): string | undefined {
|
||||
if (ts.isIdentifier(expression)) return expression.text
|
||||
return ts.isPropertyAccessExpression(expression) ? expression.name.text : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The request target one node carries, when it addresses a resource.
|
||||
* @param node - any node visited in the tree.
|
||||
* @returns the target expression, or undefined when the node is not a target.
|
||||
*/
|
||||
function requestTargetOf(node: ts.Node): ts.Expression | undefined {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const name = calleeName(node.expression)
|
||||
if ((name !== undefined && /fetch(er)?$/iu.test(name)) || node.expression.kind === ts.SyntaxKind.ImportKeyword) return node.arguments[0]
|
||||
return undefined
|
||||
}
|
||||
if (ts.isNewExpression(node)) {
|
||||
const name = calleeName(node.expression)
|
||||
return name !== undefined && REQUEST_CONSTRUCTORS[name] === true ? node.arguments?.[0] : undefined
|
||||
}
|
||||
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
||||
&& ts.isPropertyAccessExpression(node.left) && REQUEST_PROPERTIES[node.left.name.text] === true) {
|
||||
return node.right
|
||||
}
|
||||
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && REQUEST_PROPERTIES[node.name.text] === true) {
|
||||
const { initializer } = node
|
||||
if (initializer === undefined) return undefined
|
||||
return ts.isJsxExpression(initializer) ? initializer.expression : initializer
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** One literal fragment of a composed value, with the node that carries it. */
|
||||
interface LiteralFragment { node: ts.Node; text: string }
|
||||
|
||||
/**
|
||||
* Literal fragments of a value composed of literals, `+` concatenation,
|
||||
* templates, and conditionals. Template fragments are the spans between
|
||||
* substitutions, so an interpolated prefix (`${origin}/api/x`) is visible.
|
||||
* @param node - the expression to decompose.
|
||||
* @returns fragments in source order.
|
||||
*/
|
||||
function literalFragments(node: ts.Expression): LiteralFragment[] {
|
||||
if (ts.isParenthesizedExpression(node)) return literalFragments(node.expression)
|
||||
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
||||
return [...literalFragments(node.left), ...literalFragments(node.right)]
|
||||
}
|
||||
if (ts.isConditionalExpression(node)) {
|
||||
return [...literalFragments(node.whenTrue), ...literalFragments(node.whenFalse)]
|
||||
}
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [{ node, text: node.text }]
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
// Every fragment reports the template itself: an interpolated prefix makes
|
||||
// the offending span start mid-expression, and one template is one route.
|
||||
return [
|
||||
{ node, text: node.head.text },
|
||||
...node.templateSpans.map(span => ({ node, text: span.literal.text })),
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Find app-owned routes that bind one source file to one mount.
|
||||
* @param file - repository-relative path used in diagnostics.
|
||||
* @param sourceText - TypeScript or TSX source.
|
||||
* @param face - `browser` for request targets, `reference-producer` for the
|
||||
* fields a static host emits into the browser.
|
||||
* @returns violations in source order.
|
||||
*/
|
||||
export function findRouteResolutionViolations(
|
||||
file: string,
|
||||
sourceText: string,
|
||||
face: RouteGateFace = 'browser',
|
||||
): RouteResolutionViolation[] {
|
||||
const source = ts.createSourceFile(
|
||||
file,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
)
|
||||
const violations = new Map<number, RouteResolutionViolation>()
|
||||
|
||||
const report = (node: ts.Node, rule: RouteResolutionRule): void => {
|
||||
const start = node.getStart(source)
|
||||
if (violations.has(start)) return
|
||||
const { character, line } = source.getLineAndCharacterOfPosition(start)
|
||||
violations.set(start, { column: character + 1, file, line: line + 1, pattern: node.getText(source), rule })
|
||||
}
|
||||
|
||||
/** A request target's own literals must be document-relative. */
|
||||
const checkTarget = (target: ts.Expression): void => {
|
||||
for (const fragment of literalFragments(target)) {
|
||||
if (ROOT_ABSOLUTE_ROUTE.test(fragment.text) || ABSOLUTE_ROUTE.test(fragment.text)) {
|
||||
report(fragment.node, 'request-target')
|
||||
}
|
||||
}
|
||||
// A shared route key used as-is: the browser half strips the leading slash.
|
||||
for (const key of unstrippedHostRouteKeys(target)) report(key, 'host-route-key')
|
||||
}
|
||||
|
||||
/** A relative app route belongs to the document base, not to a rebuilt origin. */
|
||||
const checkUrlBase = (node: ts.NewExpression): void => {
|
||||
const [first, second] = node.arguments ?? []
|
||||
if (first === undefined || second === undefined || !readsLocation(second)) return
|
||||
const literal = literalFragments(first)
|
||||
if (literal.some(fragment => RELATIVE_APP_ROUTE.test(fragment.text))) report(first, 'location-base')
|
||||
}
|
||||
|
||||
/** A field the browser resolves against its document carries a reference. */
|
||||
const checkReferenceField = (value: ts.Expression): void => {
|
||||
for (const fragment of literalFragments(value)) {
|
||||
if (ROOT_ABSOLUTE_ROUTE.test(fragment.text) || ABSOLUTE_ROUTE.test(fragment.text)) {
|
||||
report(fragment.node, 'reference-producer')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (face === 'browser') {
|
||||
const target = requestTargetOf(node)
|
||||
if (target !== undefined) checkTarget(target)
|
||||
if (ts.isNewExpression(node) && calleeName(node.expression) === 'URL') checkUrlBase(node)
|
||||
} else if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)
|
||||
&& REFERENCE_FIELDS[node.name.text] === true && ts.isExpression(node.initializer)) {
|
||||
checkReferenceField(node.initializer)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
|
||||
return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column)
|
||||
}
|
||||
|
||||
/**
|
||||
* Source files of every browser-side TypeScript project in the Client aggregate.
|
||||
*
|
||||
* A browser half belongs to `src/client`; a package that keeps its whole UI in
|
||||
* plain `src` is found through the project itself, which carries the Client
|
||||
* compiler shape (DOM libraries). A project the Host aggregate also compiles
|
||||
* has a host half rather than a browser-only surface, so only its `src/client`
|
||||
* tree is browser face. Every such face must contribute at least one source
|
||||
* file: a face that contributes none means the aggregate's layout moved out
|
||||
* from under this discovery, which must fail loud rather than scan less.
|
||||
* @param projectRoot - repository root to discover from.
|
||||
* @returns sorted repository-relative source paths.
|
||||
*/
|
||||
export function browserFaceSources(projectRoot: string = root): string[] {
|
||||
const sources = new Set<string>()
|
||||
const add = (file: string): void => {
|
||||
const normalized = file.replaceAll('\\', '/')
|
||||
if (normalized.endsWith('.d.ts')) return
|
||||
sources.add(normalized)
|
||||
}
|
||||
const hostConfigs = faceConfigs(projectRoot, 'host').byPath
|
||||
|
||||
for (const [configPath, parsed] of faceConfigs(projectRoot, 'client').byPath) {
|
||||
if (parsed.options.lib?.includes('lib.dom.d.ts') !== true || hostConfigs.has(configPath)) continue
|
||||
const sourceRoot = `${dirname(configPath).replaceAll('\\', '/')}/src/`
|
||||
// An aggregate or app shell (the root Client program) carries the compiler
|
||||
// shape but no `src/` tree of its own; its browser sources arrive through
|
||||
// the references and `src/client` discovery below.
|
||||
if (!existsSync(sourceRoot)) continue
|
||||
const faceSources = parsed.fileNames.filter((file) => {
|
||||
const normalized = file.replaceAll('\\', '/')
|
||||
return normalized.startsWith(sourceRoot) && /\.tsx?$/.test(normalized)
|
||||
})
|
||||
if (faceSources.length === 0) {
|
||||
throw new Error(
|
||||
`${GATE}: browser face ${relative(projectRoot, configPath).split(sep).join('/')} has DOM libraries but contributes no source file; discovery is broken.`,
|
||||
)
|
||||
}
|
||||
for (const file of faceSources) add(relative(projectRoot, file).split(sep).join('/'))
|
||||
}
|
||||
|
||||
for (const file of globSync('packages/*/*/src/client/**/*.{ts,tsx}', { cwd: projectRoot })) {
|
||||
add(relative(projectRoot, resolve(projectRoot, file)).split(sep).join('/'))
|
||||
}
|
||||
|
||||
return [...sources].sort()
|
||||
}
|
||||
|
||||
/** Scan one file for one face; a producer that disappeared must fail loud. */
|
||||
function scan(file: string, face: RouteGateFace): RouteResolutionViolation[] {
|
||||
const path = resolve(root, file)
|
||||
if (face === 'reference-producer' && !existsSync(path)) {
|
||||
throw new Error(`${GATE}: browser-reference producer ${file} is gone; discovery is broken.`)
|
||||
}
|
||||
return findRouteResolutionViolations(file, readFileSync(path, 'utf8'), face)
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = browserFaceSources()
|
||||
const violations = [
|
||||
...files.flatMap(file => scan(file, 'browser')),
|
||||
...REFERENCE_PRODUCERS.flatMap(file => scan(file, 'reference-producer')),
|
||||
]
|
||||
if (violations.length > 0) {
|
||||
console.error(`${GATE}: ${violations.length} app-owned route(s) that are not document-relative:`)
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
` ${violation.file}:${violation.line}:${violation.column} ${violation.pattern} — ${HINT[violation.rule]}`,
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
`${GATE}: ${files.length} browser source file(s) and ${REFERENCE_PRODUCERS.length} browser-reference producer(s) keep app-owned routes document-relative.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.filename === resolve(process.argv[1] ?? '')) main()
|
||||
Reference in new issue
Block a user