From 92723cafeb5547fd303e6c5fca9836dc783d0ceb Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:07:39 +0800 Subject: [PATCH 1/2] fix(frontend-static): return 404 for missing paths --- ...6-08-20-explicit-web-index-paths.i18n.yaml | 6 +++ .../2026-08-20-explicit-web-index-paths.md | 27 ++++++++++ .../2026-08-20-explicit-web-index-paths.zh.md | 27 ++++++++++ docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 2 +- docs/subsystems/client-modules.zh.md | 2 +- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 4 +- docs/subsystems/web-server.zh.md | 4 +- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 3 +- packages/host/frontend-static/README.zh.md | 3 +- packages/host/frontend-static/package.json | 2 +- packages/host/frontend-static/src/index.ts | 31 +++++++----- .../tests/frontend-static.spec.ts | 49 +++++++++++++++---- 15 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.i18n.yaml new file mode 100644 index 0000000000..0efe5d0ca3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.md b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.md new file mode 100644 index 0000000000..6ff08c3c2a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.zh.md new file mode 100644 index 0000000000..7b419b9f4c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.zh.md @@ -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 的路径、路径遍历、畸形目标、不支持的方法和回退释放。 diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 1020af937b..9e4e1a9dd7 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.i18n.yaml @@ -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 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index 7003454792..ae767c6a09 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -62,7 +62,7 @@ Package metadata — including the negative "not a client package" verdict — i ## The bundle route and index injection -`GET`/`HEAD /plugins//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//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 diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index ae5a6aab03..710453e3e1 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -62,7 +62,7 @@ interface WebBootGraph { ## bundle 路由与 index 注入 -`GET`/`HEAD /plugins//client.js` 以 `no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。注入行在每次 index 渲染时携带当前图,因此刷新页面总是针对实时组合启动。 +`GET`/`HEAD /plugins//client.js` 以 `no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,因此不可读 bundle 不会表现为成功的 JavaScript 响应。注入行在每次 index 渲染时携带当前图,因此刷新页面总是针对实时组合启动。 ## 服务 diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 37263bb0c4..2c23ed2e72 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -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: 439b8ceafc363c7fa1cfa91997f39b4aa97a3397 +web-server.zh.md: 1aeb747230affcdc581c094c520cb6ceab459df4 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index c60047c2ff..439b8ceafc 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -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, the dist root and configured index path render `index.html`, missing paths 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 the 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). diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 29a46280c6..1aeb747230 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.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,dist 根目录和配置的 index 路径渲染 `index.html`,缺失路径返回空的 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) 中。 diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 22b23d33bf..3115d93aa3 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/README.i18n.yaml @@ -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: d0c99a57230d7accc93a654f21c002762e2d2df3 +README.zh.md: a1fe98be24a639d75b3b5561c837b8f977954f7d diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index fb9ab0c9fe..d0c99a5723 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -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. The dist root and configured index path render `index.html` with HTTP 200; an absent file or any other undeclared pathname 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 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. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index 53955c1f43..a1fe98be24 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -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 入口服务已构建的前端目录。dist 根目录和配置的 index 路径以 HTTP 200 渲染 `index.html`;缺失文件或其他未声明的 pathname 返回空的 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 路由。添加这类路由时必须同时添加显式服务器规则和真实组合测试,不得为每个未命中项启用宽泛回退。 diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 07b44df96b..c938a923de 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -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" diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index ad516f1c79..935a9dfebc 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -1,11 +1,11 @@ /** * @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 + * fallback seat: serves the built frontend directory with explicit index + * entry points. The dist root and configured index path render index.html; + * 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 @@ -44,14 +44,20 @@ const MIME: Record = { '.webmanifest': 'application/manifest+json', } +const STATIC_MISS_CODES: ReadonlySet = 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, @@ -79,9 +85,12 @@ export async function serveStatic( 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() + } 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() } } diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index 1b9fc9c65c..627a800d64 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -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 { 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,57 @@ 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('', '')) - 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. + // 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. From 600f3a3110135454a14228d1380420d258722194 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:38:45 +0800 Subject: [PATCH 2/2] fix(frontend-static): return 404 for a missing index --- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 4 +- docs/subsystems/web-server.zh.md | 4 +- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 +- packages/host/frontend-static/README.zh.md | 2 +- packages/host/frontend-static/src/index.ts | 46 ++++++++++--------- .../tests/frontend-static.spec.ts | 10 ++++ 8 files changed, 44 insertions(+), 32 deletions(-) diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 2c23ed2e72..9434d236d4 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -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: 439b8ceafc363c7fa1cfa91997f39b4aa97a3397 -web-server.zh.md: 1aeb747230affcdc581c094c520cb6ceab459df4 +web-server.md: c23a48cd57aeb2127107c0487cb46c9202d11605 +web-server.zh.md: 4097e26ce826067a92de7ba3ec65f790dd6ad1dd diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 439b8ceafc..c23a48cd57 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -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, the dist root and configured index path render `index.html`, missing paths are empty 404 responses, 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 the 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. +`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). diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 1aeb747230..4097e26ce8 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.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,dist 根目录和配置的 index 路径渲染 `index.html`,缺失路径返回空的 404,未知扩展名按 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 响应,随后再按注册顺序应用原始的 `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) 中。 diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 3115d93aa3..771455e06a 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/README.i18n.yaml @@ -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: d0c99a57230d7accc93a654f21c002762e2d2df3 -README.zh.md: a1fe98be24a639d75b3b5561c837b8f977954f7d +README.md: e4f3765a0471566dfb2f6780b1072d3e79788dc5 +README.zh.md: 7672478480f07b760634fef0c8640fcbdc0e6e7e diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index d0c99a5723..e4f3765a04 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -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 explicit index entry points. The dist root and configured index path render `index.html` with HTTP 200; an absent file or any other undeclared pathname 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 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. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index a1fe98be24..7672478480 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.zh.md) 的唯一回退席位,并通过显式 index 入口服务已构建的前端目录。dist 根目录和配置的 index 路径以 HTTP 200 渲染 `index.html`;缺失文件或其他未声明的 pathname 返回空的 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 解析它并挂载本插件;部署绝不硬编码它。 +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。 diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 935a9dfebc..1afd319906 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -1,13 +1,13 @@ /** * @deepseek-ai/dsh-host-frontend-static — SPA dist server over the webserver * fallback seat: serves the built frontend directory with explicit index - * entry points. The dist root and configured index path render index.html; - * 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. + * 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 = z.object({ distIndex: z.string().required(), }) +const HTML_MIME = 'text/html; charset=utf-8' + const MIME: Record = { - '.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', @@ -56,8 +58,8 @@ const STATIC_MISS_CODES: ReadonlySet = new Set([ * @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 (structured injection rendering) for - * the dist root and configured index path. + * @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, @@ -72,26 +74,26 @@ export async function serveStatic( res.end() return } - const serveIndex = async (): Promise => { - const body = await renderIndex() - res.writeHead(200, { 'content-type': MIME['.html'] }) - res.end(body) - } - if (target === distRoot || target === distIndex) { - await serveIndex() - return - } + let body: string | Buffer + let type: string try { - const body = await readFile(target) - res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' }) - res.end(body) + 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 } + res.writeHead(200, { 'content-type': type }) + res.end(body) } /** diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index 627a800d64..fda9dcbc3e 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -129,6 +129,16 @@ describe('real Loader composition', () => { untap() expect((await request(port, '/')).body).not.toContain('__T__') + // 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']