@deepseek-ai/dsh-host-webserver 0.1.1-rc.2 → 0.1.2-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/host/webserver/README.md
5
- README.md: b6424262305b062f4c57774c446ac1d50e490a03
6
- README.zh.md: ea5fd0129c121b227e4d8dbd0a8dcc162acc5f7b
5
+ README.md: 4211c640a0e7386e3380a4289e5ba619818bd094
6
+ README.zh.md: 6ad4f549cd234221cfef88ef0bfd9541fbe50043
package/README.md CHANGED
@@ -1,16 +1,102 @@
1
+ ---
2
+ description: "The web GUI host's HTTP server: named-route and upgrade registration, index transforms, and the single fallback seat that serves the Web shell's SPA dist."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-host-webserver
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); the fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload, rendering with the exported `renderIndexInjections`. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
10
+ ## Summary
11
+
12
+ Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It knows no harness concepts and serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Compose the webserver as the HTTP transport of a browser-facing host, then let the feature plugins claim their routes. Activation listens immediately; registration order carries no request-facing semantics because named routes compose to be disjoint.
29
+
30
+ ### Minimal configuration
31
+
32
+ ```yaml
33
+ - name: '@deepseek-ai/dsh-host-webserver'
34
+ config:
35
+ host: 127.0.0.1
36
+ port: 3000
37
+ ```
38
+
39
+ `host` accepts exactly two values: `127.0.0.1` (default posture, loopback only) and `0.0.0.0` (deliberate network exposure — the server carries no TLS, authentication, or origin policy of its own). `port` 0 requests an OS-assigned port; `ctx.webServer.port` reads the listening port afterwards.
40
+
41
+ Set `compression: 'gzip'` to wrap eligible socket-backed responses without changing route APIs. The client must accept gzip and the media type must be compressible; known response lengths below `compressionThresholdBytes` remain uncompressed, while unknown-length streams are eligible immediately. Existing encodings, `Cache-Control: no-transform`, range responses, SSE, ZIP, and the packaged `.gz` Worker image remain unchanged. The shipped Web bundle uses compression level 1 with a 1024-byte threshold; other compositions default to no compression.
42
+
43
+ ### Registering routes
44
+
45
+ `register(route)` adds a named `exact` or `prefix` HTTP route, `registerUpgrade(route)` adds an upgrade route for an exact pathname, and both return a disposer that removes the registration. A duplicate path within either table throws — route patterns are a composition-level contract, so a collision is a misconfiguration. HTTP matching is exact over the whole table, then longest prefix, then the fallback handler; upgrades match exactly and unmatched connections are closed.
46
+
47
+ ### The fallback seat
48
+
49
+ `registerFallback(handler)` claims the one handler for every request no named route matches. A second registration throws; while no fallback is registered the server answers 404. In the shipped Web composition the [SPA dist server](../frontend-static/README.md) owns the seat and calls `renderIndex` on every index response it renders.
50
+
51
+ Index startup inputs are two layers. `collectIndexInjections()` gathers a fresh injection table — one `webserver/index-inject` emit per call, each subscriber pushing its current rows — and `renderIndex(html)` renders those rows into the index.html body before applying the raw `tapIndex(transform)` transforms in registration order. A `script-preload` row renders an advisory classic-script preload link. Static deployments carry the same rows in their boot payload. `applyIndexTaps(html)` applies only the raw transforms; it is the escape hatch for markup no row expresses.
52
+
53
+ ### Behavior under failure
54
+
55
+ A listen failure (for example EADDRINUSE) rejects plugin initialization with the bind diagnostic. An HTTP request whose handler throws is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error logs a warning and destroys its socket.
56
+
57
+ -----
6
58
 
7
- The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell.
59
+ <a id="understand-the-implementation"></a>
60
+ ## Understand the implementation
8
61
 
9
- A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
62
+ <details>
63
+ <summary>Implementation internals — click to expand</summary>
10
64
 
65
+ ### Design concept
66
+
67
+ The package is a plain route registry with no harness vocabulary: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` and `port` getters expose composition-time facts other plugins adapt to (for example the directory-picker chooser).
68
+
69
+ ### Matching and lifecycle
70
+
71
+ `match(pathname)` consults the exact table first, then walks the prefix table for the longest match, then the fallback. Activation (`[Service.init]`) listens immediately; disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the server and those sockets have closed. Node does not include upgraded sockets in `closeAllConnections()`, so the service tracks them explicitly.
72
+
73
+ ### Source map
74
+
75
+ | File | Role |
76
+ |---|---|
77
+ | [`src/index.ts`](src/index.ts) | `WebServer` service: route tables, fallback seat, index rendering, matching, lifecycle |
78
+ | [`src/injections.ts`](src/injections.ts) | Structured `IndexInjection` rows and `renderIndexInjections` row rendering |
79
+
80
+ </details>
81
+
82
+ -----
83
+
84
+ <a id="further-exploration"></a>
85
+ ## Further Exploration
86
+
87
+ Read these when the server contract is not enough: the subsystem reference, then the fallback owner and the layering decision behind who registers which route.
88
+
89
+ - [HTTP server subsystem](../../../docs/subsystems/web-server.md) — routes, matching order, and the config the server accepts.
90
+ - [SPA dist server](../frontend-static/README.md) — the shipped owner of the fallback seat.
91
+ - [Web config-tree boot and transport layering](../../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) — why feature plugins own every route.
92
+ - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-webserver) — every accepted config field and its source declaration.
93
+
94
+ -----
95
+
96
+ <a id="model-experience"></a>
11
97
  ## Model Experience
12
98
 
13
- None, as the package is a Web carrier between the browser and the HTTP/upgrade routes other plugins register; nothing here reaches a model request.
99
+ None, as the HTTP carrier bridges browser and API handler and registers nothing model-facing.
14
100
 
15
101
  #### KV Cache effect
16
102
 
@@ -18,5 +104,20 @@ None; this package neither assembles nor sends a provider request.
18
104
 
19
105
  ## Known Limitations and Deferred Work
20
106
 
21
- - **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
107
+ <a id="known-limitations-and-deferred-work"></a>
108
+
109
+
110
+ These limits define where the server is intentionally minimal. They are current package constraints, not a task backlog.
111
+
112
+ - **No server-wide TLS, authentication, or origin policy** — route owners such as `dsh-client-connection` enforce their own request policy. Binding a non-loopback address still exposes unprotected routes and static assets to that network.
22
113
  - **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
114
+
115
+ <a id="dev-note"></a>
116
+ ### Dev Note
117
+
118
+ <details>
119
+ <summary>Working context for maintainers — click to expand</summary>
120
+
121
+ None.
122
+
123
+ </details>
package/README.zh.md CHANGED
@@ -1,22 +1,123 @@
1
+ ---
2
+ description: "web GUI 宿主的 HTTP 服务器:具名路由与 upgrade 注册、index 转换,以及服务 Web 壳 SPA dist 的唯一回退席位。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-host-webserver
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发,用导出的 `renderIndexInjections` 渲染。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
10
+ ## 概述
11
+
12
+ 浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器,其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不了解任何 harness 概念,也不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 把 webserver 组合为面向浏览器宿主的 HTTP 传输,然后让功能插件认领各自的路由。激活即开始监听;注册顺序不影响请求处理,因为具名路由组合起来互不相交。
29
+
30
+ ### 最小配置
31
+
32
+ ```yaml
33
+ - name: '@deepseek-ai/dsh-host-webserver'
34
+ config:
35
+ host: 127.0.0.1
36
+ port: 3000
37
+ ```
38
+
39
+ `host` 只接受两个值:`127.0.0.1`(默认姿态,仅回环)与 `0.0.0.0`(有意向网络开放——服务器自身不携带 TLS、认证或来源策略)。`port` 为 0 时请求 OS 分配端口;之后用 `ctx.webServer.port` 读取正在监听的端口。
40
+
41
+ 设置 `compression: 'gzip'` 可以包装符合条件的 socket-backed 响应,而不改变 route API。客户端必须接受 gzip,且媒体类型必须可压缩;已知长度小于 `compressionThresholdBytes` 的响应保持未压缩,未知长度 stream 则立即符合条件。已有编码、`Cache-Control: no-transform`、range 响应、SSE、ZIP 与已打包的 `.gz` Worker image 均保持不变。随附 Web bundle 使用 level 1 与 1024 字节阈值;其他组合默认不压缩。
42
+
43
+ ### 注册路由
44
+
45
+ `register(route)` 添加具名的 `exact`/`prefix` HTTP route,`registerUpgrade(route)` 为精确 pathname 添加 upgrade route,两者返回的 disposer 都会移除注册。同一张表内的重复路径会抛错——route 模式是组合层约定,冲突即配置错误。HTTP 匹配先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler;upgrade 只做精确匹配,未命中连接直接关闭。
46
+
47
+ ### 回退席位
48
+
49
+ `registerFallback(handler)` 认领所有未被具名 route 命中的请求的唯一个 handler。第二次注册会抛错;没有注册回退时服务器回答 404。在随附的 Web 组合中,[SPA dist 服务器](../frontend-static/README.zh.md)拥有该席位,并对其渲染的每个 index 响应调用 `renderIndex`。
50
+
51
+ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的注入表——每次调用发一次 `webserver/index-inject` 事件,每个订阅方推入其当前行——`renderIndex(html)` 先把这些行渲染进 index.html 正文,再按注册顺序应用原始 `tapIndex(transform)` 转换。`script-preload` 行会渲染为 classic script 的提示性 preload 链接。静态部署会在启动 payload 中携带同一批行。`applyIndexTaps(html)` 只应用原始转换;它是任何行都无法表达的标记的逃生口。
52
+
53
+ ### 失败时的行为
54
+
55
+ 监听失败(例如 EADDRINUSE)会以绑定诊断信息拒绝插件初始化。handler 抛错的 HTTP 请求会得到 400——若响应头已经发出则销毁 socket——并记录 warning;它绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。
56
+
57
+ -----
6
58
 
7
- 该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认安全姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。
59
+ <a id="understand-the-implementation"></a>
60
+ ## 理解实现
8
61
 
9
- 监听失败(EADDRINUSE……)会从激活过程抛出,并以绑定诊断信息拒绝 Loader 组合;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
62
+ <details>
63
+ <summary>实现细节——点击展开</summary>
10
64
 
65
+ ### 设计理念
66
+
67
+ 本包是一个不带任何 harness 词汇的普通路由注册表:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退槽位、原始 index 转换列表,以及 index 渲染器经其收集行的 `webserver/index-inject` 事件。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 与 `port` getter 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器)。
68
+
69
+ ### 匹配与生命周期
70
+
71
+ `match(pathname)` 先查精确表,再遍历前缀表取最长匹配,最后走回退。激活(`[Service.init]`)即开始监听;资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在服务器与这些 socket 均已关闭后返回。Node 的 `closeAllConnections()` 不包含升级 socket,因此服务显式跟踪它们。
72
+
73
+ ### 源码地图
74
+
75
+ | 文件 | 职责 |
76
+ |---|---|
77
+ | [`src/index.ts`](src/index.ts) | `WebServer` 服务:路由表、回退席位、index 渲染、匹配、生命周期 |
78
+ | [`src/injections.ts`](src/injections.ts) | 结构化 `IndexInjection` 行与 `renderIndexInjections` 行渲染 |
79
+
80
+ </details>
81
+
82
+ -----
83
+
84
+ <a id="further-exploration"></a>
85
+ ## 进一步探索
86
+
87
+ 当服务器约定不够用时阅读以下内容:先看子系统参考,再看回退持有者,以及谁注册哪条路由背后的分层决策。
88
+
89
+ - [HTTP 服务器子系统](../../../docs/subsystems/web-server.zh.md)——路由、匹配顺序与服务器接受的配置。
90
+ - [SPA dist 服务器](../frontend-static/README.zh.md)——回退席位的随附持有者。
91
+ - [Web 配置树启动与传输分层](../../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)——功能插件为何拥有每条路由。
92
+ - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-webserver)——每个受支持配置字段及其源声明。
93
+
94
+ -----
95
+
96
+ <a id="model-experience"></a>
11
97
  ## 模型体验
12
98
 
13
- 无。该包只是浏览器与其他插件所注册 HTTP/upgrade route 之间的 Web 载体,其中没有任何内容会进入模型请求。
99
+ 无。该 HTTP 载体只桥接浏览器与 API handler,不注册任何面向模型的内容。
14
100
 
15
101
  #### KV Cache 影响
16
102
 
17
103
  无;该包既不组装也不发送提供方请求。
18
104
 
19
- ## 已知限制与暂缓事项
105
+ ## 已知限制与延期工作
106
+
107
+ <a id="known-limitations-and-deferred-work"></a>
108
+
109
+
110
+ 这些限制说明服务器在何处有意保持最小。它们是当前包约束,不是任务积压。
20
111
 
21
- - **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。
112
+ - **不提供服务器级 TLS、认证或来源策略**:`dsh-client-connection` 等 route owner 会实施自己的请求策略。绑定非回环地址仍会向该网络公开未受保护的 route 与静态资源。
22
113
  - **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。
114
+
115
+ <a id="dev-note"></a>
116
+ ### 开发备注
117
+
118
+ <details>
119
+ <summary>维护者的工作上下文——点击展开</summary>
120
+
121
+ 无。
122
+
123
+ </details>
package/lib/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createServer } from "node:http";
2
2
  import { Service } from "@deepseek-ai/cordis";
3
3
  import z from "@deepseek-ai/schemastery";
4
+ import compressionMiddleware from "compression";
5
+ import Negotiator from "negotiator";
4
6
  //#region lib/types/injections.js
5
7
  /**
6
8
  * Structured index injections: the typed rows plugins contribute to the boot
@@ -33,6 +35,10 @@ function renderRow(row) {
33
35
  placement: row.placement,
34
36
  markup: `<script src="${escapeHtmlAttribute(row.src)}"><\/script>`
35
37
  };
38
+ case "script-preload": return {
39
+ placement: "head",
40
+ markup: `<link rel="preload" as="script" href="${escapeHtmlAttribute(row.src)}">`
41
+ };
36
42
  case "style": return {
37
43
  placement: "head",
38
44
  markup: `<style>${row.text}</style>`
@@ -49,9 +55,18 @@ function splice(html, at, markup) {
49
55
  return `${html.slice(0, at)}${markup}${html.slice(at)}`;
50
56
  }
51
57
  /**
58
+ * Tail script settling the boot-readiness deferred (`__DSH_BOOT_READY__`):
59
+ * the client entry awaits its `.promise` before reading any injected state.
60
+ * Whichever side runs first creates the deferred (`??=`), so a bootstrap that
61
+ * applies the table asynchronously installs it ahead of the entry module and
62
+ * settles it after the last row; the served form below creates and resolves
63
+ * it in one statement, because every row is already in the document text.
64
+ */
65
+ const READY_MARKUP = "<script>(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()<\/script>";
66
+ /**
52
67
  * Render rows into an index.html body: head rows immediately after the
53
68
  * opening head tag, body rows immediately after the opening body tag, each
54
- * group in table order.
69
+ * group in table order, and the boot-readiness tail after the last body row.
55
70
  * @param html - the raw index.html body.
56
71
  * @param rows - the collected injection table.
57
72
  * @returns the html with every row rendered.
@@ -64,6 +79,7 @@ function renderIndexInjections(html, rows) {
64
79
  if (rendered.placement === "head") head += rendered.markup;
65
80
  else body += rendered.markup;
66
81
  }
82
+ body += READY_MARKUP;
67
83
  let out = html;
68
84
  if (head !== "") {
69
85
  const open = /<head(?:\s[^>]*)?>/i.exec(out);
@@ -78,14 +94,40 @@ function renderIndexInjections(html, rows) {
78
94
  //#endregion
79
95
  //#region lib/types/index.js
80
96
  /**
81
- * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
82
- * server plus the `webServer` service (HTTP and upgrade route registries, the
83
- * structured index injection table with raw transform taps behind it, and the
84
- * single fallback seat for everything no route claims). Knows no harness concepts and serves no files; the composing
85
- * application's frontend plugin owns dist serving through the fallback hook.
86
- * Web shape only — Electron loads dist over file:// and carries fetch over an
87
- * IPC bridge. This package never prints: the URL line belongs to the shell.
97
+ * @deepseek-ai/dsh-host-webserver — node:http route registration with optional
98
+ * gzip, index injection, and one fallback seat. It knows no harness concepts
99
+ * and serves no files; the composing application owns dist serving. Electron
100
+ * uses file:// plus IPC instead, and this package never prints the URL.
101
+ * Route handlers retain direct response ownership.
88
102
  */
103
+ const DEFAULT_COMPRESSION = "none";
104
+ const DEFAULT_COMPRESSION_LEVEL = 1;
105
+ const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 1024;
106
+ function createGzipMiddleware(config) {
107
+ const middleware = compressionMiddleware({
108
+ level: config.compressionLevel,
109
+ threshold: config.compressionThresholdBytes,
110
+ filter(request, response) {
111
+ if (response.getHeader("content-range") !== void 0) return false;
112
+ const contentType = response.getHeader("content-type");
113
+ if (typeof contentType === "string" && contentType.toLowerCase().startsWith("text/event-stream")) return false;
114
+ return compressionMiddleware.filter(request, response);
115
+ }
116
+ });
117
+ return (req, res, next) => {
118
+ if (res.socket === void 0) {
119
+ next();
120
+ return;
121
+ }
122
+ const encoding = new Negotiator(req).encoding(["gzip", "identity"]);
123
+ const gzipRequest = Object.create(req);
124
+ Object.defineProperty(gzipRequest, "headers", { value: {
125
+ ...req.headers,
126
+ "accept-encoding": encoding === "gzip" ? "gzip" : "identity"
127
+ } });
128
+ middleware(gzipRequest, res, next);
129
+ };
130
+ }
89
131
  /**
90
132
  * The browser HTTP carrier service. Activation listens immediately. Route
91
133
  * registration order does not affect requests because configured named routes
@@ -97,7 +139,10 @@ var WebServer = class extends Service {
97
139
  config;
98
140
  static Config = z.object({
99
141
  host: z.union([z.const("127.0.0.1"), z.const("0.0.0.0")]).required(),
100
- port: z.natural().max(65535).required()
142
+ port: z.natural().max(65535).required(),
143
+ compression: z.union([z.const("none"), z.const("gzip")]).default(DEFAULT_COMPRESSION),
144
+ compressionLevel: z.number().step(1).min(0).max(9).default(DEFAULT_COMPRESSION_LEVEL),
145
+ compressionThresholdBytes: z.natural().default(DEFAULT_COMPRESSION_THRESHOLD_BYTES)
101
146
  });
102
147
  exact = /* @__PURE__ */ new Map();
103
148
  prefixes = /* @__PURE__ */ new Map();
@@ -107,9 +152,12 @@ var WebServer = class extends Service {
107
152
  fallback;
108
153
  server;
109
154
  listenedPort;
155
+ gzip;
110
156
  constructor(ctx, config) {
111
157
  super(ctx, "webServer");
112
158
  this.config = config;
159
+ const resolved = config;
160
+ this.gzip = resolved.compression === "gzip" ? createGzipMiddleware(resolved) : void 0;
113
161
  }
114
162
  /** The listening port (the OS-assigned value when config.port is 0). */
115
163
  get port() {
@@ -195,15 +243,19 @@ var WebServer = class extends Service {
195
243
  await fallback(req, res);
196
244
  };
197
245
  this.server = createServer((req, res) => {
198
- handle(req, res).catch((err) => {
199
- this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)));
200
- if (res.headersSent) {
201
- res.destroy();
202
- return;
203
- }
204
- res.writeHead(400);
205
- res.end();
206
- });
246
+ const next = () => {
247
+ handle(req, res).catch((err) => {
248
+ this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)));
249
+ if (res.headersSent) {
250
+ res.destroy();
251
+ return;
252
+ }
253
+ res.writeHead(400);
254
+ res.end();
255
+ });
256
+ };
257
+ if (this.gzip === void 0) next();
258
+ else this.gzip(req, res, next);
207
259
  });
208
260
  this.server.on("upgrade", (req, socket, head) => {
209
261
  const onError = (error) => {
@@ -1,11 +1,9 @@
1
1
  /**
2
- * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
3
- * server plus the `webServer` service (HTTP and upgrade route registries, the
4
- * structured index injection table with raw transform taps behind it, and the
5
- * single fallback seat for everything no route claims). Knows no harness concepts and serves no files; the composing
6
- * application's frontend plugin owns dist serving through the fallback hook.
7
- * Web shape only — Electron loads dist over file:// and carries fetch over an
8
- * IPC bridge. This package never prints: the URL line belongs to the shell.
2
+ * @deepseek-ai/dsh-host-webserver — node:http route registration with optional
3
+ * gzip, index injection, and one fallback seat. It knows no harness concepts
4
+ * and serves no files; the composing application owns dist serving. Electron
5
+ * uses file:// plus IPC instead, and this package never prints the URL.
6
+ * Route handlers retain direct response ownership.
9
7
  */
10
8
  import type { IncomingMessage, ServerResponse } from 'node:http';
11
9
  import type { Duplex } from 'node:stream';
@@ -46,12 +44,18 @@ export interface WebUpgradeRoute {
46
44
  /** Owns protocol negotiation and the upgraded socket after dispatch. */
47
45
  handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>;
48
46
  }
49
- /** Gateway config: the listen address. */
47
+ /** Web server listen and response-compression config. */
50
48
  export interface Config {
51
49
  /** Listen host; the two supported values are loopback and all-interfaces. */
52
50
  host: '127.0.0.1' | '0.0.0.0';
53
51
  /** Listen port; zero requests an OS-assigned port. */
54
52
  port: number;
53
+ /** Response compression for socket-backed HTTP requests. @default 'none' */
54
+ compression?: 'none' | 'gzip';
55
+ /** Gzip DEFLATE level from 0 through 9. @default 1 */
56
+ compressionLevel?: number;
57
+ /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
58
+ compressionThresholdBytes?: number;
55
59
  }
56
60
  /**
57
61
  * The browser HTTP carrier service. Activation listens immediately. Route
@@ -71,6 +75,7 @@ export declare class WebServer extends Service {
71
75
  private fallback;
72
76
  private server;
73
77
  private listenedPort;
78
+ private readonly gzip;
74
79
  constructor(ctx: Context, config: Config);
75
80
  /** The listening port (the OS-assigned value when config.port is 0). */
76
81
  get port(): number;
@@ -33,6 +33,11 @@ export type IndexInjection =
33
33
  placement: IndexInjectionPlacement;
34
34
  src: string;
35
35
  }
36
+ /** Advisory preload for an external classic script; static workers may ignore it. */
37
+ | {
38
+ kind: 'script-preload';
39
+ src: string;
40
+ }
36
41
  /** A `<style>` element in the head. `text` must not contain `</style`, which would close the element early. */
37
42
  | {
38
43
  kind: 'style';
@@ -47,7 +52,7 @@ export type IndexInjection =
47
52
  /**
48
53
  * Render rows into an index.html body: head rows immediately after the
49
54
  * opening head tag, body rows immediately after the opening body tag, each
50
- * group in table order.
55
+ * group in table order, and the boot-readiness tail after the last body row.
51
56
  * @param html - the raw index.html body.
52
57
  * @param rows - the collected injection table.
53
58
  * @returns the html with every row rendered.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-host-webserver",
3
3
  "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
4
- "version": "0.1.1-rc.2",
4
+ "version": "0.1.2-alpha.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,14 +32,18 @@
32
32
  ],
33
33
  "license": "MIT",
34
34
  "peerDependencies": {
35
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
36
- "@deepseek-ai/cordis": "^4.0.1"
35
+ "@deepseek-ai/cordis": "^4.0.2",
36
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3"
37
37
  },
38
38
  "dependencies": {
39
- "@deepseek-ai/schemastery": "^3.18.1"
39
+ "compression": "^1.8.1",
40
+ "negotiator": "^1.0.0",
41
+ "@deepseek-ai/schemastery": "^3.18.2"
40
42
  },
41
43
  "devDependencies": {
42
- "@deepseek-ai/cordis": "^4.0.1",
43
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
44
+ "@types/compression": "^1.8.1",
45
+ "@types/negotiator": "^0.6.5",
46
+ "@deepseek-ai/cordis": "^4.0.2",
47
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3"
44
48
  }
45
49
  }