客户端资源与模块
审计基线 0.1.5-alpha.1 @ 5dda764ed3;见 源码 / npm 渠道。
一句话版:浏览器端的"活数据"统一由资源地址表达——一个
dsh-resource://<type>/…地址对应一个资源,协议归属包用ctx.resources.register注册唯一 provider,任何 slot 组件用useResource<P>(address)读它;右侧栏则是ui-dockkit(宿主无关引擎)+ui-sidebar-right(产品宿主)+ 两个 tab 类型包(files/text)的组合。
这四个包都是 0.1.5-alpha.1 新增的。读完你能回答:一个地址怎么变成会自我更新的值、插件怎么把自己的协议接进来、dockkit 为什么不是插件、以及文件树点开一个文件之后到底发生了什么。
一、资源模型:一个地址,一个 provider
- 资源地址是
dsh-resource://<type>/…形式的 URL,host 段就是协议名(RESOURCE_SCHEME = 'dsh-resource',protocolOf(address)取 host)。 - 需要作用域的协议把作用域编进 path,例如
dsh-resource://file/session/<sessionId>/<相对工作区根的路径>与dsh-resource://file/absolute/<绝对路径>。模型本身只认识地址。 - 其它 scheme(如
sidebar://guide)是导航地址,不命名任何资源。 ResourceProtocolMap(声明在packages/client/ui-slots/src/index.ts)是"协议 → 值类型"的声明合并登记表:消费方按类型参数取值类型,不必 import owner 的运行时。
二、ctx.resources:注册、按住、订阅
| 成员 | 约定 |
|---|---|
register<P>(provider) | 为某个协议注册唯一 provider;重复注册抛错。返回幂等 disposer(调用方自己放进 ctx.effect) |
pin(address, signal) | 不订阅也把资源按住,直到 signal abort;已 abort 的 signal 什么也不 pin |
source(address) | hook 背后的裸 observable;getSnapshot() 读取不持有资源 |
provider 契约(src/client/contract.ts):
interface ResourceProvider<P> {
readonly protocol: P
open(address: string, ctx: { signal: AbortSignal }): AsyncIterable<RemoteResult<Value>>
reload?(address: string): void
}
三条硬约定:
open的第一帧是当前内容,之后每帧一个变化;- 失败是帧,不是抛错——
{ ok: false, error }让资源进入failed并保留上一帧的值;流里抛异常是编程错误,不会被捕获; - 必须响应
signal:最后一个 holder 释放时流被 abort。
生命周期:每个地址一条 record,页面级存活、永不回收(只丢弃状态)。holder 计数 = 订阅者 + pin;第一个 holder 打开流,之后的 holder 共享;最后一个释放时 abort 并把快照重置为 idle。保留 record 是为了让 source() 在 React"先渲染再订阅"的窗口和 StrictMode 重挂载之间保持引用稳定。
三、useResource:四态
@deepseek-ai/dsh-client-resources 的浏览器半部在 apply 顶层建好 registry,然后:
ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })
于是每个 slot 组件都在 props 上拿到 useResource(与作用域无关)。
status | 含义 |
|---|---|
none | 该协议的 provider 未注册,或地址不是 dsh-resource:// |
loading | provider 已开、还没出第一帧 |
live | value 是最近一帧 ok 的值 |
failed | 最近一帧失败;value 保留上一帧,failure 是这次的错误 |
reload() 向 provider 要一帧新的;协议没有 provider 或没有 reload 时是空操作。
四、client 模块系统:dsh.client → 浏览器 bundle
浏览器插件不是被宿主 import 的,而是声明 + 懒加载:
| 声明 | 作用 |
|---|---|
package.json#dsh.client.platform: "web" | 声明这是一个浏览器插件 |
exports["./client"] | 浏览器半部的入口 bundle |
dsh.client.inject[] | 模块图依赖(bundle 加载顺序)——与 cordis 的 inject 服务依赖是两回事,别混 |
dsh.client.external[] | 平台 seed 表之外需要额外共享的模块请求 |
@deepseek-ai/dsh-client-modules 的宿主半部扫描启用的 Loader 条目、组成 boot graph(注入为 window.__DSH_BOOT__)、经 /plugins 提供 bundle;浏览器半部懒加载:执行一个 bundle 只注册 factory,模块体(含 CSS 注入)到 materialization 时才跑。
ui-dockkit 不是插件:它没有 dsh.client,用 tsdown 的 staticLinked 预设静态链接进消费方 bundle(packages/client/tsdown.client.ts)。它是库,不是可装载的浏览器插件——这也解释了它 README 里"内部引擎、不承诺稳定 API"的定位。
五、右侧栏的组合
ui-sidebar-right(inject = ['slots', 'layout', 'locale', 'resources'])提供两个服务与一个域:
| 名字 | 角色 |
|---|---|
ctx.sidebarRight | 导航与布局控制器:openResource / openTab / close / active / isExpanded / toggleExpanded / focus / split / float / dock |
ctx.sidebarRightTabs | tab 类型注册表:register(definition) / candidates(address) / claim(address, kind?) |
| Tab 域 | 每个 (Session, tab id) 保留 navigation、abort signal 与绑定 actions;只有记录移除或插件卸载才 abort |
它同时把 dockkit 接到产品上:每个打开的 tab 通过 ctx.resources.pin(address, signal) 把资源按住,所以切走再切回来能立刻读到最新值,而不用重开流。
座位(slot)一览,全部由 ui-sidebar-right 运行时声明:
| 座位 | 种类 | 用途 |
|---|---|---|
rightbar | 组件 | 面板本体;它声明子座位 sidebar.right.pane.tab(keyed)、.title(keyed)、sidebar.right.tab.menu.item(list) |
conversation.session.header.corner | 组件 | 面板收起时的展开按钮 |
sidebar.right.pane.tab | keyed | tab 的身体,键 = 类型定义的 id |
sidebar.right.tab.guide | chain | 替换 guide 页内容而不替换 tab |
sidebar.right.tab.menu.item | list | 追加内容级菜单项(布局手势由 dockkit 自己拥有) |
六、tab 类型两阶段注册
- 类型:
ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide? })——纯静态声明,返回 disposer;id是实现在 tab 系统里的身份,重复注册抛错。 - 身体:
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body),body 用useTabInfo()读{ sidebar, panel, tab }。
路由规则(编辑器解析器惯例):
| 维度 | 规则 |
|---|---|
| band | extension(默认,最高)> builtin > fallback |
| pattern | 含 : 匹配整个地址(dsh-resource://file/**);不含则匹配 URI 的 path 任意深度(*.md,忽略大小写) |
| 同 band 排序 | 匹配到的 pattern 更长者优先,再按注册顺序 |
| 否决 | canOpen(address) 返回 false 的候选直接出局 |
两个出厂类型:
| 包 | kind | priority | patterns | 角色 |
|---|---|---|---|---|
ui-sidebar-files | files | builtin | 无(页面,不认领地址) | 工作区文件树;guide 条目 order 10 |
ui-sidebar-textpreview | text | fallback | dsh-resource://file/** | 纯文本查看器;任何更具体的类型都能抢走地址 |
ui-sidebar-files 是"页面":它不认领地址,guide 里给一个入口,树里的文件行通过 tab.actions.openResource 打开,交给 file 的查看器去认领。
七、贯通示例:一个 dsh-resource://file/… 地址的一生
关键分工:元数据走资源流,正文走分页调用。file 资源的值只有 { absolutePath, version, bytes?, changed };文本由查看器自己一页页读,所以 Host 报"文件变了"时,屏幕上的旧文本不会被悄悄替换,而是出现一条可重载的提示条。
八、源码佐证
| 位置 | 符号 / 事实 |
|---|---|
packages/client/resources/src/client/contract.ts | ResourceProvider、Resources、ResourceSnapshot、UseResource、ResourceStatus、ResourceOpenContext、ctx.resources 声明合并 |
packages/client/resources/src/client/resources.ts | RESOURCE_SCHEME、protocolOf、ResourceRegistry、ResourceRecord |
packages/client/resources/src/client/index.ts | inject = ['slots']、provideRoot({ keyedHooks: { resource } }) |
packages/client/resources/src/index.ts | 宿主半部为空(apply(): void {})——资源模型只活在浏览器 |
packages/client/ui-slots/src/index.ts | interface ResourceProtocolMap {}(声明合并宿主) |
packages/client/modules/README.md | dsh.client 声明、platform: 'web'、exports["./client"]、dsh.client.external、window.__DSH_BOOT__、/plugins |
packages/client/tsdown.client.ts | staticLinked、isStaticLinkedConfig、clientBundle |
packages/client/ui-dockkit/src/contract/types.ts | LayoutState、LayoutOp、PaneId/SplitId/TabId、TabRecord、DockMode、DockZone |
packages/client/ui-dockkit/src/contract/adapter.ts | DockLabels、TabRenderer、TabMenuExtras、DockIntents |
packages/client/ui-sidebar-right/src/client/index.ts | inject、ctx.reflect.provide('sidebarRight' / 'sidebarRightTabs')、rightbar 与 conversation.session.header.corner 座位 |
packages/client/ui-sidebar-right/src/client/service.ts | ISidebarRight 十个方法 |
packages/client/ui-sidebar-right/src/client/tab-registry.ts | SidebarRightTabPriority(extension/builtin/fallback)、register、candidates、claim、coexists |
packages/client/ui-sidebar-right/src/client/contract/params.ts | SidebarRightResourceParamsMap、SidebarRightTabParamsMap、SidebarRightNavigationParams |
packages/client/ui-sidebar-files/src/client/definition.ts | FILES_KIND = 'files'、FILES_ID、priority: 'builtin'、guide order 10 |
packages/client/ui-sidebar-textpreview/src/client/definition.ts | TEXTPREVIEW_KIND = 'text'、patterns: ['dsh-resource://file/**']、priority: 'fallback'、canOpen |
packages/client/ui-sidebar-textpreview/src/client/rpc.ts | hostFileOf、createReadPage、WorkspaceFilesReadRemote |
packages/bundle/web-app/cordis.patch.yml | resources / ui-sidebar-right / ui-sidebar-textpreview / ui-sidebar-files 四行 |
九、验证
# 1. 浏览器插件名册与装载顺序(resources 在 ui-sidebar-right 之前)
dsh web --dump-config | grep -iE "modules|connection|resources|ui-sidebar|ui-dockkit"
# 2. dockkit 没有 dsh.client,是静态链接库而非插件
grep -n '"dsh"' packages/client/ui-dockkit/package.json || echo "no dsh.client: static-linked library"
grep -n "staticLinked" packages/client/ui-dockkit/tsdown.config.ts
# 3. 协议与地址语法
grep -n "dsh-resource" packages/client/resources/src/client/resources.ts \
packages/api/workspace-files/src/client/types.ts
# 4. 浏览器里:打开会话右侧栏 → 文件标签 → 点开一个文本文件。
# DevTools 应看到 POST /api 的 workspaceFiles.stat / read,
# 以及 /api/remote.mux 上按会话共享的 workspaceFiles.changes 流。
一个可观察的行为:同一个文件在两个不同会话里打开是两个 tab(地址不同),而同一路径重复打开只会聚焦已有 tab 并投递新的 navigation.params——内容身份是整条地址。
下一步
- 工作区文件服务:
file协议背后的 Host 端点 - Web UI 架构:双进程、slot 体系与 client 插件
- Remote API 网关:资源流下的传输与鉴权
- 插件解剖:dual-face 包的形态与清单