refactor(webserver): extract SPA dist serving to the frontend-static fallback seat

The webserver's built-in static dist serving becomes a single-owner fallback
seat (registerFallback/applyIndexTaps); the SPA server moves to the new
@deepseek-ai/dsh-frontend-static plugin so the composing application owns its
dist as composition, not carrier config. distIndex leaves the webserver
schema; unclaimed fallback answers 404.
This commit is contained in:
Turtle
2026-08-06 04:39:52 +08:00
parent d0cb6770a9
commit 2ee2ee2f96
23 changed files with 567 additions and 141 deletions

View File

@@ -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/webserver/README.md
README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4
README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977
README.md: b6dccf2f81c9e2f0b9f53264eafe724edb560f07
README.zh.md: dbfe420013ed67c48e47048341f020864aeef16a

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `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. `tapIndex(transform)` adds an index.html transform applied in registration order, `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 static dist fallback with the 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 octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `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)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `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 seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. 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); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
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). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
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 malformed %-escape hitting `decodeURIComponent`, 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.
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.
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
@@ -21,5 +21,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **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.
- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route同一张表内的重复路径会抛错因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route再匹配最长前缀最后回退到静态 dist并遵循固定语义越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 octet-stream 提供GETHEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route同一张表内的重复路径会抛错因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route再匹配最长前缀最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route插件 bundle 与 HMR热模块替换事件流是 moduleshmr 插件的 route。upgrade handler 拥有协议握手与连接内容webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web浏览器形态Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch而不使用本服务器。该包从不打印内容URL 行属于 shell。
该包不了解任何 harness 概念,也不提供任何文件服务`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route插件 bundle 与 HMR热模块替换事件流是 moduleshmr 插件的 routedist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web浏览器形态Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch而不使用本服务器。该包从不打印内容URL 行属于 shell。
监听失败EADDRINUSE……会从激活过程抛出以 bind 诊断使 Loader 组合 reject失败的候选 fiber 会被 dispose资源释放。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400若响应头已经发出则销毁 socket并记录 warning但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()``closeAllConnections()`,销毁所有受跟踪的升级 socket并仅在 HTTP server 与这些 socket 均已关闭后返回。
监听失败EADDRINUSE……会从激活过程抛出以 bind 诊断使 Loader 组合 reject失败的候选 fiber 会被 dispose资源释放。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400若响应头已经发出则销毁 socket并记录 warning但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()``closeAllConnections()`,销毁所有受跟踪的升级 socket并仅在 HTTP server 与这些 socket 均已关闭后返回。
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map再统一发布因此基线失败会保留先前的图。这样即时重建不会消失在异步建立的监听基线中重命名窗口会把路径标记为脏保留最近一次成功基线并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
@@ -21,5 +21,4 @@ Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配
## 已知限制与延期工作
- **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。
- **初始 MIME 表很精简**Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。
- **Socket 选项固定不变**配置只选择绑定宿主与端口在具体部署产生需求前backlog 和其他 socket 设置仍保持内部实现。

View File

@@ -1,21 +1,19 @@
/**
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
* server plus the `httpServer` service (HTTP and upgrade route registries,
* index transform taps, and static dist fallback). Knows no harness concepts;
* feature plugins own every registered protocol. Web shape only — Electron
* loads dist over file:// and carries fetch over an IPC bridge. This package
* never prints: the URL line belongs to the shell.
* index transform taps, and the single fallback seat for everything no route
* claims). Knows no harness concepts and serves no files; the composing
* application's frontend plugin owns dist serving through the fallback seam.
* Web shape only — Electron loads dist over file:// and carries fetch over an
* IPC bridge. This package never prints: the URL line belongs to the shell.
*/
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
import { readFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import type { Duplex } from 'node:stream'
import { dirname } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { serveStatic } from './static.ts'
declare module 'cordis' {
interface Context {
@@ -43,28 +41,26 @@ export interface WebUpgradeRoute {
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
}
/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
/** Gateway config: the listen address. */
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */
distIndex: string
}
/**
* The web-shape HTTP carrier service. Activation listens immediately (route
* registration order carries no request-facing semantics: named routes are
* composed to be disjoint, and the static dist fallback answers anything not
* yet claimed during the boot window). A listen failure throws out of init —
* a FAILED fiber the boot's fail-loud sweep reports.
* composed to be disjoint, and the fallback seat answers anything not yet
* claimed during the boot window — 404 until its owner registers). A listen
* failure throws out of init — a FAILED fiber the boot's fail-loud sweep
* reports.
*/
export class HttpServerService extends Service {
static Config: z<Config> = z.object({
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
port: z.natural().max(65535).required(),
distIndex: z.string().required(),
})
private readonly exact = new Map<string, WebRoute>()
@@ -72,15 +68,12 @@ export class HttpServerService extends Service {
private readonly upgrades = new Map<string, WebUpgradeRoute>()
private readonly upgradedSockets = new Set<Duplex>()
private readonly indexTaps: ((html: string) => string)[] = []
private readonly distRoot: string
private readonly distIndex: string
private fallback: WebRoute['handler'] | undefined
private server!: Server
private listenedPort!: number
constructor(ctx: Context, private config: Config) {
super(ctx, 'httpServer')
this.distIndex = config.distIndex
this.distRoot = dirname(config.distIndex)
}
/** The listening port (the OS-assigned value when config.port is 0). */
@@ -123,8 +116,24 @@ export class HttpServerService extends Service {
}
/**
* Register an index.html transform, applied to every index response in
* registration order.
* Claim the fallback seat: the handler answering every request no named
* route matches (the SPA dist server in the shipped Web composition). One
* owner only — a second registration throws, because two fallbacks cannot
* compose.
* @param handler - owns the full response lifecycle of unmatched requests.
* @returns the disposer releasing the seat.
*/
registerFallback(handler: WebRoute['handler']): () => void {
if (this.fallback !== undefined) {
throw new Error('webserver: fallback already registered')
}
this.fallback = handler
return () => { this.fallback = undefined }
}
/**
* Register an index.html transform, applied by the fallback owner to every
* index response ({@link applyIndexTaps}) in registration order.
* @param transform - pure html-to-html function.
* @returns the disposer removing the transform.
*/
@@ -147,14 +156,13 @@ export class HttpServerService extends Service {
await route.handler(req, res)
return
}
// Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405,
// traversal 403, miss falls back to index.html 200 (SPA routing).
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
const fallback = this.fallback
if (fallback === undefined) {
res.writeHead(404)
res.end()
return
}
await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex())
await fallback(req, res)
}
// Last-resort guard: handle() rejecting would otherwise be an unhandled
// rejection killing the process on one malformed request (bad %-escape,
@@ -243,11 +251,16 @@ export class HttpServerService extends Service {
return best
}
/** Index body: dist index.html through the registered taps in order. */
private async renderIndex(): Promise<string> {
let html = await readFile(this.distIndex, 'utf8')
for (const transform of this.indexTaps) html = transform(html)
return html
/**
* Run an index.html body through the registered taps in registration order
* — called by the fallback owner on every index response it renders.
* @param html - the raw index.html body.
* @returns the transformed body.
*/
applyIndexTaps(html: string): string {
let out = html
for (const transform of this.indexTaps) out = transform(out)
return out
}
}

View File

@@ -1,60 +0,0 @@
/**
* Static file serving for the web shell: the starter MIME table and the
* request handler with the semantics locked by the step1 acceptance list —
* 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.
*/
import type { ServerResponse } from 'node:http'
import { extname, join, normalize, resolve, sep } from 'node:path'
import { readFile } from 'node:fs/promises'
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.json': 'application/json',
'.map': 'application/json',
}
/**
* 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 - when set, produces the index.html body (boot-manifest
* injection) for `/` and every SPA fallback; undefined serves the file verbatim.
*/
export async function serveStatic(
pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
renderIndex?: () => Promise<string>,
): Promise<void> {
const target = resolve(normalize(join(distRoot, pathname)))
// Traversal rejection: the target must be distRoot itself (`/`) or stay under
// it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/'
// suffix would reject every legitimate subpath as traversal.
if (target !== distRoot && !target.startsWith(distRoot + sep)) {
res.writeHead(403)
res.end()
return
}
const serveIndex = async (): Promise<void> => {
const body = renderIndex === undefined ? await readFile(distIndex) : await renderIndex()
res.writeHead(200, { 'content-type': MIME['.html'] })
res.end(body)
}
if (target === distRoot || target === distIndex) {
await serveIndex()
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()
}
}

View File

@@ -2,11 +2,10 @@
* REAL-composition coverage: a test-only cordis.yml booted through the
* vendored Loader mounts the webserver row, and every assertion observes the
* user-visible HTTP surface of the running server (routing precedence, index
* taps, static-fallback semantics, per-request error containment, teardown).
* taps, fallback-seat semantics, per-request error containment, teardown).
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir } from 'node:fs/promises'
import { once } from 'node:events'
import { connect } from 'node:net'
import { tmpdir } from 'node:os'
@@ -28,21 +27,15 @@ afterEach(async () => {
root = undefined
})
/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */
/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */
async function loadComposition(port = 0): Promise<Context> {
root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
const dist = join(root, 'dist')
await mkdir(dist)
const distIndex = join(dist, 'index.html')
await writeFile(distIndex, '<head></head><body>shell</body>')
await writeFile(join(dist, 'app.js'), 'export {}')
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
" host: '127.0.0.1'",
` port: ${String(port)}`,
` distIndex: '${distIndex}'`,
'',
].join('\n'))
@@ -96,7 +89,7 @@ describe('real Loader composition', () => {
// Real-Loader composition resolves workspace packages through tsx at test
// time; first resolution after the host/client program split is slow enough
// to trip the default 5s budget on cold caches.
it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => {
it('serves registered routes, index taps, and the fallback-seat semantics', { timeout: 60_000 }, async () => {
const loaded = await loadComposition()
const unloaded = [...loaded.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
@@ -120,21 +113,24 @@ describe('real Loader composition', () => {
expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' })
expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' })
// Index taps apply in registration order on `/` and on the SPA fallback;
// the disposer removes the transform.
// Fallback seat: 404 while unclaimed; the owner answers everything no
// named route matches; index taps are the owner's to apply; the seat
// admits exactly one owner and the disposer releases it.
expect((await request(port, '/no/such/route')).status).toBe(404)
const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
expect((await request(port, '/')).body).toContain('__T__')
expect(server.applyIndexTaps('<head></head>')).toContain('__T__')
const releaseFallback = server.registerFallback((req, res) => {
// Decode like a real static server would — a malformed %-escape throws
// here, probing the webserver's per-request error containment.
decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
res.writeHead(200, { 'content-type': 'text/html' })
res.end(server.applyIndexTaps('<head></head><body>shell</body>'))
})
expect(() => server.registerFallback(() => {})).toThrow(/fallback already registered/)
expect((await request(port, '/no/such/route')).body).toContain('__T__')
untap()
expect((await request(port, '/')).body).not.toContain('__T__')
// Static fallback semantics: real asset served, traversal 403, non-GET/
// HEAD without a matching route 405.
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' })
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' })
expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
expect((await request(port, '/no/such/route')).body).not.toContain('__T__')
expect((await request(port, '/no/such/route')).body).toContain('shell')
// Per-request error containment: a malformed %-escape answers 400 and the
// server keeps serving afterwards (no process-level failure path).
@@ -148,9 +144,14 @@ describe('real Loader composition', () => {
const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } })
expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' })
disposeOnce()
expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
expect((await request(port, '/once')).body).toContain('shell') // back to the fallback owner
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
// Releasing the seat restores the unclaimed 404 and registrability.
releaseFallback()
expect((await request(port, '/no/such/route')).status).toBe(404)
expect(() => server.registerFallback(() => {})).not.toThrow()
// Upgrade routes match exact pathnames, reject duplicate ownership, and
// become registrable again after disposal. The accepted socket stays open
// so the teardown assertion also covers upgraded-connection ownership.