dsh 插件开发学习

04-注册 HTTP 路由与注入页面样式

读完这篇你能:写一个插件,让浏览器能拿到你的图片,并把 DSH 页面背景换掉。

前置:03-用 patch 覆盖 DSH 的默认配置。约 25 分钟。

示例代码:dsh-chat-background

DSH 版本:0.2.0-rc.2

03-用 patch 覆盖 DSH 的默认配置把 patch 讲完了,但它改的都是 DSH 自带的那几行。现在回到我们自己的插件——写一个真正能动 UI 的。实现如下效果:

目标

写一个插件,把一张本地图片设为 DSH 的聊天背景。

DSH 设计

先看服务器本身。DSH 的 webServer 就是一个 HTTP 服务器加一张路由表,它自己不了解任何 DSH 概念,也不提供任何文件:/api 桥接、插件包资源、HMR 事件流、打包好的页面文件,全都由插件往表里注册。所以「让浏览器能访问某个地址」不是改服务器配置,而是和内置功能走同一条路:往表里注册一行。服务器一激活就开始监听;handler 拿到请求后,拥有响应的全部控制权。

这张路由表是为「组合」设计的,不是为「顺序」设计的。注册进去的是具名路由:exact 逐字匹配一个路径,prefix 匹配一个前缀及其下所有路径。匹配顺序是固定的:先查精确匹配,再取最长前缀,最后交给唯一一个「回退席位」接管;没人认领时,服务器答 404。重复注册同一路径会直接抛错——路由归属是插件之间的组合约定,撞了就是配置错,不是谁先到先得。也正因为具名路由互不相交,注册先后不影响任何一次请求的结果。

mermaid 图表

回退席位目前由发行版预置的一个静态页面插件认领。它把 Web 壳的构建产物(dist 目录)端给浏览器,官方文档叫它 SPA dist 服务器。认领手段和其他插件毫无区别,就是 ctx.webServer.registerFallback(handler),外面用 ctx.effect 包着注册;插件卸载时席位释放,未命中的请求回到 404。

路由之外,webServer 还提供另一个扩展点:页面注入表。路由回答的是「有请求时谁来应答」,注入表回答的是「返回页面 HTML 之前,谁能往里面加东西」。想往页面里塞 <style>、<script> 这类启动所需的东西,正规通道不是改 HTML 文件,而是订阅 webserver/index-inject 事件。每次要返回页面时,webServer 新建一张空表并广播这个事件,每个订阅者把自己当前的行 push 进去,最后按行自带的定位渲染进 HTML:该进 head 的插在 <head> 开标签后,该进 body 的插在 <body> 开标签后。

这里的关键在于,行是推到那一刻现读的,而且每返回一次页面就重新采集一遍。主题偏好、模块图都是当场取值,所以运行期切换主题、装卸插件,下一次刷新页面就跟着变,不存在表里数据过期这回事。插件卸载时,订阅随 fiber 销毁,它推过的行也一起消失。

最后一个问题是时机。插件想用 webServer,可轮到它启动的那一刻,服务器可能还没就绪。插件之间不约启动顺序,只声明服务依赖:插件声明自己需要哪些服务,框架等服务就绪再启动它,也就是「加载顺序通过服务依赖表达,而非手动编排启动序列」(docs/cordis-primer.zh.md)。代码里就是 ctx.inject(['webServer'], ...)——这不是可选的等待工具,而是「我要用某个服务」的正规声明。

一句话点题:服务器只提供路由表和事件,页面上能看到的一切,都是各个插件认领和推入的。

两条链路

hello world 只是打印一句话,而这个插件要「动 UI」。页面是 DSH 自己渲染的,插件想影响它,需要打通两条路(机制就是上文的第一个和第三个):

  1. 让浏览器能拿到图片:图片在插件目录里,浏览器可访问不到你的磁盘。所以我们要在 DSH 的 webServer 上注册一个 HTTP 路由:浏览器请求某个地址时,服务器把图片文件返回给它。
  2. 让页面穿上背景:页面不知道「背景长什么样」。DSH 提供了一个钩子 webserver/index-inject,允许插件往每个页面的 <head> 里注入一段 <style>。
mermaid 图表

目录结构

dsh-chat-background/                    ← 插件包(bundle)
├── src/                                ← 源码目录
│   ├── dsh-chat-background.ts          ← 插件本体:name + apply
│   └── style.css                       ← 背景样式(独立文件,方便改)
├── image.jpg                           ← 你的背景图片
├── package.json                        ← 身份 + dsh.bundle 声明
├── patch.yaml                          ← 挂载指令
└── tsconfig.json                       ← 类型配置(让编辑器认识 node:fs)

图片准备

把想用的背景图放到插件根目录,本教程用 image.jpg。

为什么图片不能写进代码?一是几 MB 的图会让代码文件爆炸;二是浏览器根本读不到你磁盘上的文件,必须走 HTTP 路由,这是唯一的正规通道。

插件代码

// src/dsh-chat-background.ts
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
// 只引入类型声明(module augmentation),编译后剥离,运行时零开销
import type {} from '@deepseek-ai/dsh-host-webserver'

export const name = 'dsh-chat-background'

// 图片在插件根目录(../),样式表与源码同目录;都用 import.meta.url 解析
const IMAGE_PATH = new URL('../image.jpg', import.meta.url).pathname
const IMAGE_URL = '/dsh-chat-background/image.jpg'
const STYLE = readFileSync(new URL('./style.css', import.meta.url), 'utf8')

export function apply(ctx: Context): void {
    // 1. 注册路由:浏览器请求 IMAGE_URL 时返回图片
    ctx.inject(['webServer'], (serverCtx) => {
        const webServer = serverCtx.webServer
        ctx.effect(() => webServer.register({
            kind: 'exact',
            path: IMAGE_URL,
            handler: async (_req, res) => {
                res.writeHead(200, { 'content-type': 'image/jpeg' })
                res.end(await readFile(IMAGE_PATH))
            },
        }))
    })

    // 2. 注入样式:把 style.css 内容原样注入每个 index 响应
    ctx.on('webserver/index-inject', (table) => {
        table.push({ kind: 'style', text: STYLE })
    })
}

这段代码做了两件事:第 1 段注册图片路由,第 2 段注入样式。我们按顺序拆开讲。

注册图片路由

ctx.inject(['webServer'], (serverCtx) => {
    const webServer = serverCtx.webServer
    ctx.effect(() => webServer.register({
        kind: 'exact',
        path: IMAGE_URL,
        handler: async (_req, res) => {
            res.writeHead(200, { 'content-type': 'image/jpeg' })
            res.end(await readFile(IMAGE_PATH))
        },
    }))
})

表面看只是”注册一个路由”,其实是三层嵌套,每一层解决一个问题。

最外层 ctx.inject(['webServer'], ...):声明”我依赖 webServer”

webServer 是 DSH 内置的 HTTP 服务器服务(Cordis Service)。它自己也有启动过程——要监听端口、准备就绪。插件执行到 apply() 时,webServer 很可能还没准备好。ctx.inject 就是告诉 Cordis:”我依赖 webServer,等它可用了再叫我”。Cordis 会替我们等,服务就绪后才执行里面的回调。

中间层 webServer.register({...}):往路由表里加一条

register 接收一个路由描述对象:

  • kind: 'exact':路径逐字匹配。浏览器请求的地址必须和 path 一模一样(/dsh-chat-background/image.jpg 一个字符都不能差)。另一个可选值是 'prefix',匹配 path 和 path/<任意内容>,适合托管一个目录。
  • handler:拥有响应的完整生命周期。服务器把请求交给 handler,写什么状态码、返回什么内容都由它决定(甚至可以挂起连接做 SSE)。所以我们必须自己 res.writeHead(...) 声明状态码和类型,res.end(...) 结束响应。
  • 重复注册同一条 (kind, path) 会直接抛错——DSH 认为路由是”组合级契约”,撞了就是配置错误。

handler 内部就三行:res.writeHead(200, { 'content-type': 'image/jpeg' }) 告诉浏览器”这是张 JPEG 图片”;await readFile(IMAGE_PATH) 把插件目录里的图片读进内存;res.end(...) 把字节流发给浏览器。

register 还有返回值:一个注销函数(把这条路由从表里删掉)。这个返回值是留给最内层用的。

最内层 ctx.effect(...):把注销函数绑进插件生命周期

ctx.effect(() => webServer.register({ ... }))

ctx.effect 接收一个函数,该函数会立即执行,函数的返回值也是一个函数,当卸载当前插件时执行清理动作(05-插件生命周期与 ctx.effect着重讲解)。

等插件被卸载(禁用、移除、重载)时,Cordis 会逆序执行所有清理函数。

于是 register 返回的注销函数就有了归属:插件活着,路由在;插件卸载,路由自动消失。

这里不使用 ctx.effect 包装 webServer.register 也是可以的,路由一样可以注册进去,只是卸载插件时不会执行清理动作,下次再启用插件时 register 就会抛 duplicate exact route 异常。

  • 请求来了会发生什么
mermaid 图表

注入样式

ctx.on('webserver/index-inject', (table) => {
    table.push({ kind: 'style', text: STYLE })
})

webserver/index-inject 是 DSH 的「往页面里塞东西」事件:每次浏览器来要页面 HTML 之前,DSH 都会重新广播一次,table 是刚新建的空列表,各插件往里面 push 自己当前的行。我们 push 一条 style,内容就是 style.css 的全文,DSH 会把它渲染成 <style>...</style> 放进页面的 <head>。

new URL('./style.css', import.meta.url):以插件源码文件自己的位置为基准找文件,不依赖你从哪个目录启动 DSH(这是比写相对路径更稳的写法)。

如果你问我为什么我知道是要监听webserver/index-inject这个事件?事实上我也不知道。写这个教程时,将 dsh 源码克隆下来放到目录中,AI 可以帮你解决一切。

样式表

/* src/style.css */
/* 布局根(AppFrame.frame)铺一张背景图,同时覆盖侧边栏与聊天区 */
div:has(> [data-shell-overlay]) {
  background-image: url('/dsh-chat-background/image.jpg');
  background-size: cover;
}

/* 侧边栏列:局部置空 fill 变量,透出布局根背景 */
div:has(> [data-shell-overlay]) > div:first-child {
  --dsw-specific-sidebar-fill: transparent;
}

/* 聊天列容器(AppFrame 里含 main 槽位的那一列,自身背景是底色):
   用 :has 定位那一列,不绑 CSS module 的哈希类名 */
div:has(> [data-shell-overlay]) > div:has([data-slot='main']) {
  background: transparent;
}

/* 会话根(ConversationMainPanel,自身带 data-phase);conversation 那条
   留着兼容旧版槽位名 */
div:has(> [data-shell-overlay]) [data-slot='main'] > [data-phase],
div:has(> [data-shell-overlay]) [data-slot='conversation'] > [data-phase] {
  background: transparent;
}

bundle 文件

package.json

{
  "name": "dsh-chat-background",
  "version": "0.1.0",
  "type": "module",
  "dependencies": {
    "@deepseek-ai/cordis": "^4.0.1",
    "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2"
  },
  "devDependencies": {
    "@types/node": "^24.0.0"
  },
  "dsh": {
    "bundle": {
      "patch": "./patch.yaml"
    }
  }
}

@deepseek-ai/dsh-host-webserver 是 DSH 的 web 服务包,前面写插件时说过——它负责提供 ctx.webServer 和 webserver/index-inject 的类型声明。装上它,代码里的类型才会齐全。

(前面提到的 @deepseek-ai/dsh-host-frontend-static 是另一个包,它在发行版组合里认领回退席位;本插件不需要它——页面文件由它返回给浏览器,我们只往表里 inject 一行样式。)

patch.yaml

- insert:
    - id: dsh-chat-background
      name: '/你的插件绝对路径/dsh-chat-background/src/dsh-chat-background.ts'

安装与验证

cd 你的插件目录
pnpm install          # 装上依赖(含 dsh-host-webserver)
dsh plugin --profile test add link:.
dsh --profile test --dump-config   # 插件树里应出现 dsh-chat-background
dsh --profile test --no-open       # 启动,web 端口 3080