mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #2808 from deepseek-harness/fix/frontend-static-miss-404
fix(frontend-static): return 404 for missing paths
This commit is contained in:
@@ -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/bug-fix/2026-08-20-explicit-web-index-paths.md
|
||||
2026-08-20-explicit-web-index-paths.md: 6ff08c3c2a18ffd79ef7a048f563a91e41a504da
|
||||
2026-08-20-explicit-web-index-paths.zh.md: 7b419b9f4c8b4b0de20660b3f70d12e67ab264ff
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Explicit Web index paths and 404 static misses
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-20-explicit-web-index-paths.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An unconditional SPA fallback makes every unmatched GET or HEAD request look successful. A broken ordinary link and a missing JavaScript, stylesheet, source map, or manifest then receive the HTML shell with status 200, so browsers, caches, and monitoring cannot distinguish a valid page entry from an absent resource.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-host-frontend-static` renders `index.html` only when the normalized target is the dist root or the configured index path. The current Web client has no History API pathname routes; query strings do not change pathname matching, and URL fragments never reach the server. Existing files are served normally, while `ENOENT`, `EISDIR`, and `ENOTDIR` reads produce an empty 404 response with no content type. Other filesystem failures are rethrown to the webserver's request-failure handling instead of being mislabeled as absence.
|
||||
|
||||
GET and HEAD use the same status and content type for index entries, files, and misses. Named routes still match before the fallback, traversal outside the dist root remains 403, and non-GET/HEAD requests reaching the fallback remain 405.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Infer page routes from the absence of a file extension.** A file extension does not declare a client route: this would still turn unknown ordinary paths into successful pages, reject any future dotted client route, and mishandle extensionless static files when they are absent.
|
||||
|
||||
**Use an `Accept: text/html` request header as the fallback rule.** The header expresses representation preference, not whether the pathname is a declared client route. Browser fetches, bots, and monitors may request HTML for an invalid path, so the same false-success behavior remains.
|
||||
|
||||
**Add a configurable pathname allowlist now.** No current client route consumes such configuration. A future History API router can add an explicit server rule or configuration field together with the route that requires it, without preserving a speculative public option today.
|
||||
|
||||
## Consequences
|
||||
|
||||
Broken links and missing assets have distinct HTTP state that caches and monitoring can observe, and an asset loader cannot execute the HTML shell as JavaScript. A future pathname-based client route returns 404 until its server entry rule and real-composition coverage land in the same change. The frontend-static real Loader test pins GET/HEAD parity for index entries, existing assets, ordinary misses, and resource misses; it also covers API-like paths, traversal, malformed targets, unsupported methods, and fallback disposal.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: 显式 Web index 路径与静态资源未命中的 404
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-20-explicit-web-index-paths.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
无条件 SPA 回退会让每个未匹配的 GET 或 HEAD 请求看起来都成功。失效的普通链接,以及缺失的 JavaScript、样式表、source map 或 manifest,都会收到状态码为 200 的 HTML 外壳,导致浏览器、缓存与监控无法区分有效页面入口和缺失资源。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-host-frontend-static` 只在规范化目标为 dist 根目录或配置的 index 路径时渲染 `index.html`。当前 Web 客户端没有 History API pathname 路由;查询字符串不会改变 pathname 匹配,URL 片段也不会到达服务器。现有文件照常提供,而 `ENOENT`、`EISDIR` 和 `ENOTDIR` 读取产生不带内容类型的空 404 响应。其他文件系统失败会重新抛给 webserver 的请求失败处理,不会被错误标记为缺失。
|
||||
|
||||
GET 与 HEAD 对 index 入口、文件和未命中项使用相同的状态码与内容类型。具名路由仍先于回退匹配,越出 dist 根目录的遍历仍返回 403,到达回退的非 GET/HEAD 请求仍返回 405。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**根据路径没有文件扩展名来推断页面路由。** 文件扩展名不会声明客户端路由:这种做法仍会把未知普通路径变成成功页面,会拒绝未来任何带点号的客户端路由,也会在缺少无扩展名静态文件时错误处理该请求。
|
||||
|
||||
**把 `Accept: text/html` 请求头作为回退规则。** 该请求头表达的是内容表示偏好,而不是 pathname 是否为已声明的客户端路由。浏览器 fetch、机器人和监控都可能为无效路径请求 HTML,因此仍会产生同样的假成功行为。
|
||||
|
||||
**立即添加可配置的 pathname 允许列表。** 当前没有客户端路由消费这项配置。未来的 History API 路由可以在引入所需路由时,同时添加显式服务器规则或配置字段,无需现在保留推测性的公开选项。
|
||||
|
||||
## 后果
|
||||
|
||||
失效链接与缺失资源具有可供缓存和监控观察的独立 HTTP 状态,资源加载器也不会把 HTML 外壳当作 JavaScript 执行。未来基于 pathname 的客户端路由会保持 404,直到同一项变更同时加入服务器入口规则与真实组合测试。frontend-static 的真实 Loader 测试固定了 index 入口、现有资源、普通未命中和资源未命中的 GET/HEAD 状态一致性,并覆盖类似 API 的路径、路径遍历、畸形目标、不支持的方法和回退释放。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md
|
||||
client-modules.md: 7003454792f4ffed1776d1ab3b1df8e811caca49
|
||||
client-modules.zh.md: ae5a6aab0391453540a772472250a693be72710f
|
||||
client-modules.md: ae767c6a098cb1b7188c06e61efd2798eb93ff66
|
||||
client-modules.zh.md: 710453e3e10c298ed8c2b87bbab6e8e9eb154a87
|
||||
|
||||
@@ -62,7 +62,7 @@ Package metadata — including the negative "not a client package" verdict — i
|
||||
|
||||
## The bundle route and index injection
|
||||
|
||||
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404 rather than letting the carrier's SPA fallback ship HTML as JavaScript. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404, so no unreadable bundle appears as a successful JavaScript response. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
|
||||
## The service
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ interface WebBootGraph {
|
||||
|
||||
## bundle 路由与 index 注入
|
||||
|
||||
`GET`/`HEAD /plugins/<id>/client.js` 以 `no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。注入行在每次 index 渲染时携带当前图,因此刷新页面总是针对实时组合启动。
|
||||
`GET`/`HEAD /plugins/<id>/client.js` 以 `no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,因此不可读 bundle 不会表现为成功的 JavaScript 响应。注入行在每次 index 渲染时携带当前图,因此刷新页面总是针对实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
|
||||
web-server.md: c60047c2ffb45b7316e994bd3a606f4b82a872dc
|
||||
web-server.zh.md: 29a46280c603bca89fa7f552b9813ccffb0f6bcc
|
||||
web-server.md: c23a48cd57aeb2127107c0487cb46c9202d11605
|
||||
web-server.zh.md: 4097e26ce826067a92de7ba3ec65f790dd6ad1dd
|
||||
|
||||
@@ -24,7 +24,7 @@ interface WebRoute {
|
||||
}
|
||||
```
|
||||
|
||||
Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: non-GET/HEAD is 405, traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), and unknown extensions ship as octet-stream.
|
||||
Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: non-GET/HEAD is 405, traversal outside the dist root is 403, a readable index renders at the dist root and configured index path, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -42,7 +42,7 @@ interface Config {
|
||||
|
||||
## The service
|
||||
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into every index response — `/` and each SPA fallback — before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ interface WebRoute {
|
||||
}
|
||||
```
|
||||
|
||||
匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,任何未命中都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 发送。
|
||||
匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,可读的 index 在 dist 根目录和配置的 index 路径渲染,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -42,7 +42,7 @@ interface Config {
|
||||
|
||||
## 服务
|
||||
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进每个 index 响应(`/` 和每次 SPA 回退),随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
|
||||
|
||||
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/frontend-static/README.md
|
||||
README.md: fb9ab0c9fea6fdaffdc08ecb63b17ffff9ff1e16
|
||||
README.zh.md: 53955c1f43f9404d3a0f58117649c4b79e2cf692
|
||||
README.md: e4f3765a0471566dfb2f6780b1072d3e79788dc5
|
||||
README.zh.md: 7672478480f07b760634fef0c8640fcbdc0e6e7e
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with the shell's locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route is 405. Every index response is rendered through the webserver's `renderIndex` — structured injection rows first, then the raw index taps — which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it.
|
||||
SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with explicit index entry points. While `distIndex` is readable, the dist root and configured index path render `index.html` with HTTP 200; other existing files are served directly. An absent or non-file target inside the dist root, including a missing configured index, returns an empty 404; traversal outside the dist root returns 403, unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route returns 405. Every successful index response is rendered through the webserver's `renderIndex` — structured injection rows first, then the raw index taps — which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it.
|
||||
|
||||
The fallback seat is single-owner (a second claim throws) and effect-scoped: disposing the plugin's fiber releases the seat, after which the unclaimed webserver answers 404.
|
||||
|
||||
@@ -17,3 +17,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The starter MIME table is minimal** — it covers the Vite-emitted asset set plus the shipped PWA manifest; other extensions fall back to `application/octet-stream` until an asset class actually ships.
|
||||
- **Pathname routing is explicit** — the current client enters through the root or configured index path and has no History API pathname routes. Adding one requires an explicit server rule and real-composition coverage rather than a broad fallback for every miss.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.zh.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名路由时返回 405。每个 index 响应都经 webserver 的 `renderIndex` 渲染——先结构化注入行、后原始 index 转换器——启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.zh.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。
|
||||
Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.zh.md) 的唯一回退席位,并通过显式 index 入口服务已构建的前端目录。`distIndex` 可读时,dist 根目录和配置的 index 路径以 HTTP 200 渲染 `index.html`;其他现有文件直接提供。dist 根目录内缺失或不是文件的目标——包括缺失的配置 index——返回空的 404;越出 dist 根目录的遍历返回 403,未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名路由时返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 渲染——先结构化注入行、后原始 index 转换器——启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.zh.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。
|
||||
|
||||
回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束:dispose(资源释放)插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。
|
||||
|
||||
@@ -17,3 +17,4 @@ Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`)
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **初始 MIME 表很精简**:它覆盖 Vite 输出的资产集合及实际交付的 PWA manifest;其他扩展名在相应资产类别实际发布前都会回退到 `application/octet-stream`。
|
||||
- **pathname 路由是显式的**:当前客户端只从根路径或配置的 index 路径进入,没有 History API pathname 路由。添加这类路由时必须同时添加显式服务器规则和真实组合测试,不得为每个未命中项启用宽泛回退。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-frontend-static",
|
||||
"description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback",
|
||||
"description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses",
|
||||
"version": "0.1.0-rc.8",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-frontend-static — SPA dist server over the webserver
|
||||
* fallback seat: serves the built frontend directory with the semantics the
|
||||
* Web shell locked at step1 — traversal outside the dist root is 403, any
|
||||
* miss falls back to index.html with HTTP 200 (SPA routing), unknown
|
||||
* extensions ship as octet-stream, non-GET/HEAD is 405. Every index response
|
||||
* runs through the webserver's index render (structured injection rows, then
|
||||
* raw taps). The dist location is workspace knowledge of the composing
|
||||
* application, so `distIndex` is typically supplied through a `!!js`
|
||||
* expression, never hardcoded by a deployment.
|
||||
* fallback seat: serves the built frontend directory with explicit index
|
||||
* entry points. A readable index renders at the dist root and configured index
|
||||
* path; missing paths return 404, traversal outside the dist root is 403,
|
||||
* unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Every
|
||||
* index response runs through the webserver's index render (structured
|
||||
* injection rows, then raw taps). The dist location is workspace knowledge of
|
||||
* the composing application, so `distIndex` is typically supplied through a
|
||||
* `!!js` expression, never hardcoded by a deployment.
|
||||
* @module @deepseek-ai/dsh-host-frontend-static
|
||||
*/
|
||||
|
||||
@@ -34,8 +34,10 @@ export const Config: z<Config> = z.object({
|
||||
distIndex: z.string().required(),
|
||||
})
|
||||
|
||||
const HTML_MIME = 'text/html; charset=utf-8'
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.html': HTML_MIME,
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
@@ -44,14 +46,20 @@ const MIME: Record<string, string> = {
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
}
|
||||
|
||||
const STATIC_MISS_CODES: ReadonlySet<string | undefined> = new Set([
|
||||
'ENOENT',
|
||||
'EISDIR',
|
||||
'ENOTDIR',
|
||||
])
|
||||
|
||||
/**
|
||||
* Serve one GET/HEAD static request from the dist root.
|
||||
* @param pathname - decoded URL pathname of the request.
|
||||
* @param res - the node:http response to write.
|
||||
* @param distRoot - absolute dist root directory (resolved by the caller).
|
||||
* @param distIndex - absolute path of index.html inside distRoot.
|
||||
* @param renderIndex - produces the index.html body (injection rendering) for
|
||||
* `/` and every SPA fallback.
|
||||
* @param renderIndex - produces the index.html body (structured injection
|
||||
* rendering) for the dist root and configured index path.
|
||||
*/
|
||||
export async function serveStatic(
|
||||
pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
|
||||
@@ -66,23 +74,26 @@ export async function serveStatic(
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const serveIndex = async (): Promise<void> => {
|
||||
const body = await renderIndex()
|
||||
res.writeHead(200, { 'content-type': MIME['.html'] })
|
||||
res.end(body)
|
||||
}
|
||||
if (target === distRoot || target === distIndex) {
|
||||
await serveIndex()
|
||||
let body: string | Buffer
|
||||
let type: string
|
||||
try {
|
||||
if (target === distRoot || target === distIndex) {
|
||||
body = await renderIndex()
|
||||
type = HTML_MIME
|
||||
} else {
|
||||
body = await readFile(target)
|
||||
type = MIME[extname(target)] ?? 'application/octet-stream'
|
||||
}
|
||||
} catch (error) {
|
||||
// Only absent or non-file targets are 404; other filesystem failures reach
|
||||
// the webserver's request-failure handling.
|
||||
if (!STATIC_MISS_CODES.has((error as NodeJS.ErrnoException).code)) throw error
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(target)
|
||||
res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' })
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing).
|
||||
await serveIndex()
|
||||
}
|
||||
res.writeHead(200, { 'content-type': type })
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* REAL-composition coverage: a test-only cordis.yml booted through the
|
||||
* vendored Loader mounts the webserver and frontend-static rows, and every
|
||||
* assertion observes the served HTTP surface — asset serving, MIME fallback,
|
||||
* SPA index fallback with index taps, traversal rejection, 405 on non-GET/
|
||||
* HEAD, and seat release on fiber disposal (HMR safety).
|
||||
* assertion observes the served HTTP surface — asset serving, explicit index
|
||||
* entry points with index taps, 404 misses, traversal rejection, 405 on non-
|
||||
* GET/HEAD, and seat release on fiber disposal (HMR safety).
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -37,6 +37,7 @@ async function loadComposition(): Promise<Context> {
|
||||
await writeFile(join(dist, 'app.js'), 'export {}')
|
||||
await writeFile(join(dist, 'blob.bin'), 'BLOB')
|
||||
await writeFile(join(dist, 'manifest.webmanifest'), '{}')
|
||||
await mkdir(join(dist, 'empty'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-host-webserver'",
|
||||
@@ -84,7 +85,7 @@ async function request(port: number, path: string, init?: RequestInit): Promise<
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
it('serves the dist with SPA fallback, taps, traversal rejection, and method gating', { timeout: 60_000 }, async () => {
|
||||
it('serves explicit index entries and files while preserving HTTP error semantics', { timeout: 60_000 }, async () => {
|
||||
const loaded = await loadComposition()
|
||||
const unloaded = [...loaded.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
@@ -100,27 +101,67 @@ describe('real Loader composition', () => {
|
||||
type: 'application/manifest+json',
|
||||
body: '{}',
|
||||
})
|
||||
expect(await request(port, '/app.js', { method: 'HEAD' })).toEqual({
|
||||
status: 200,
|
||||
type: 'text/javascript; charset=utf-8',
|
||||
body: '',
|
||||
})
|
||||
await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
|
||||
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
|
||||
|
||||
// Unknown extension ships as octet-stream.
|
||||
expect(await request(port, '/blob.bin')).toMatchObject({ status: 200, type: 'application/octet-stream', body: 'BLOB' })
|
||||
|
||||
// `/`, the index path, and any miss all render index.html (SPA routing)
|
||||
// through the registered index taps.
|
||||
// Only the root and index path render index.html through registered taps.
|
||||
const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
|
||||
for (const path of ['/', '/index.html', '/no/such/route']) {
|
||||
for (const path of ['/', '/index.html', '/?fixture']) {
|
||||
const got = await request(port, path)
|
||||
expect(got.status).toBe(200)
|
||||
expect(got.type).toBe('text/html; charset=utf-8')
|
||||
expect(got.body).toContain('__T__')
|
||||
expect(got.body).toContain('shell')
|
||||
}
|
||||
expect(await request(port, '/', { method: 'HEAD' })).toEqual({
|
||||
status: 200,
|
||||
type: 'text/html; charset=utf-8',
|
||||
body: '',
|
||||
})
|
||||
untap()
|
||||
expect((await request(port, '/')).body).not.toContain('__T__')
|
||||
|
||||
// Traversal outside the dist root is 403; non-GET/HEAD is 405.
|
||||
// A missing configured index follows the same empty-404 contract for both
|
||||
// of its public entry paths and for both supported methods.
|
||||
await rm(join(root!, 'dist', 'index.html'))
|
||||
for (const path of ['/', '/index.html']) {
|
||||
const get = await request(port, path)
|
||||
const head = await request(port, path, { method: 'HEAD' })
|
||||
expect(get).toEqual({ status: 404, type: null, body: '' })
|
||||
expect(head).toEqual(get)
|
||||
}
|
||||
|
||||
// Ordinary unknown paths and static-resource misses are empty 404s for
|
||||
// both GET and HEAD; neither class can be mistaken for the HTML shell.
|
||||
const ordinaryMisses = ['/no/such/route', '/api/no/such/route', '/empty', '/app.js/child']
|
||||
const assetMisses = [
|
||||
'/missing.js',
|
||||
'/missing.css',
|
||||
'/missing.mjs',
|
||||
'/missing.js.map',
|
||||
'/missing.webmanifest',
|
||||
'/missing.manifest',
|
||||
]
|
||||
for (const path of [...ordinaryMisses, ...assetMisses]) {
|
||||
const get = await request(port, path)
|
||||
const head = await request(port, path, { method: 'HEAD' })
|
||||
expect(get).toEqual({ status: 404, type: null, body: '' })
|
||||
expect(head).toEqual(get)
|
||||
}
|
||||
|
||||
// Traversal outside the dist root is 403, non-GET/HEAD is 405, and a
|
||||
// malformed filesystem target still reaches the webserver's 400 guard.
|
||||
expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
|
||||
expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
|
||||
expect((await request(port, '/app.js', { method: 'POST' })).status).toBe(405)
|
||||
expect((await request(port, '/bad%00path')).status).toBe(400)
|
||||
|
||||
// HMR safety: disposing the frontend row releases the fallback seat (the
|
||||
// unclaimed webserver answers 404) and the seat is claimable again.
|
||||
|
||||
Reference in New Issue
Block a user