Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # packages/client/ui-conversation/src/client/queue/QueueDock.tsx
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* events domain contract: signatures and frame unions for the two SSE
|
||||
* events domain contract: signatures and frame unions for the two logical
|
||||
* streams. Four-quadrant: streams yield the narrow form `RpcRequest<Frame>` (server-request
|
||||
* view) — rpcId must be exposed to the business layer, because responses to answerable frames
|
||||
* (approval/question requested) echo it; for pure pushes it identifies that one push.
|
||||
@@ -42,7 +42,7 @@ export interface QueuedInboxItem {
|
||||
message: Message
|
||||
}
|
||||
|
||||
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
|
||||
/** Streaming face of the contract: the two logical stream openers (mux + host). */
|
||||
export interface EventsApi {
|
||||
/**
|
||||
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* apiproxy contract-layer barrel. api/ has zero Node dependencies and is
|
||||
* importable from the browser; the TS interfaces are the authoritative contract, HTTP/SSE are
|
||||
* merely physical channels (four-quadrant message model).
|
||||
* importable from the browser; the TS interfaces are the authoritative contract, while HTTP,
|
||||
* WebSocket, and in-process SSE are merely physical channels (four-quadrant message model).
|
||||
*/
|
||||
|
||||
import type { SessionsApi } from './sessions.ts'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Four-quadrant RPC message model. Channels and messages are
|
||||
* decoupled: HTTP is the client→server physical channel, SSE the server→client one; logical
|
||||
* messages are channel-independent, and the wire full form is a four-member discriminated union.
|
||||
* Four-quadrant RPC message model. Channels and messages are decoupled: HTTP,
|
||||
* WebSocket, and in-process SSE are physical carriers, while logical messages
|
||||
* are channel-independent and form a four-member discriminated union.
|
||||
* api/ contract layer: zero Node dependencies, importable from the browser.
|
||||
*/
|
||||
|
||||
@@ -147,7 +147,7 @@ export interface ServerResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Message initiated by the server (wire carrier: SSE frame). Answerable interactions
|
||||
* Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions
|
||||
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
|
||||
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
|
||||
* response is expected is determined statically by method (a strict dichotomy, no third kind).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting,
|
||||
* four-quadrant envelope wrap/unwrap, zod parsing, SSE frame decoding, and the payload-direct
|
||||
* four-quadrant envelope wrap/unwrap, zod parsing, in-process SSE frame decoding, and the payload-direct
|
||||
* IApiClient domain methods (business code never mints). Platform differences ride two aspects:
|
||||
* abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
|
||||
*/
|
||||
@@ -69,8 +69,8 @@ import {
|
||||
* Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
|
||||
* carry only that external signal. In both cases the signal rides beside the request, never
|
||||
* on the wire, like the stream signatures.
|
||||
* Stream methods accept an optional onOpen callback: it fires once the SSE transport is
|
||||
* readable (response headers received, before any frame) — the "stream established" signal
|
||||
* Stream methods accept an optional onOpen callback: it fires once the physical transport is
|
||||
* readable (before any frame) — the "stream established" signal
|
||||
* connection controllers need for the readiness handshake. Generators are lazy, so the
|
||||
* underlying fetch (and therefore onOpen) only happens once iteration starts.
|
||||
* Relationship: ApiProxy is the narrow-form signature contract the impl side implements;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* platform subclasses on the client side), and the host-side implementation
|
||||
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
|
||||
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
|
||||
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
|
||||
* routes — physical carriers wrap `ctx.apiProxy` themselves.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
@@ -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: c3c7b222683bc7731a6c21f2fffd325225099bab
|
||||
README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db
|
||||
README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4
|
||||
README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Plain HTTP 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` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `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). The 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, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
|
||||
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.
|
||||
|
||||
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `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: 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.
|
||||
|
||||
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A 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. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request.
|
||||
None, as the package is a Web carrier between the browser and the HTTP/upgrade routes other plugins register; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
|
||||
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 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
|
||||
|
||||
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`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(热模块替换)事件流则是 modules/hmr 插件的 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。
|
||||
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
|
||||
|
||||
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包只是浏览器与其他插件所注册路由之间的纯 HTTP 载体,其中没有任何内容会进入模型请求。
|
||||
无。该包只是浏览器与其他插件所注册 HTTP/upgrade route 之间的 Web 载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-webserver",
|
||||
"description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts",
|
||||
"description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a
|
||||
* node:http server plus the `httpServer` service (named-route registry + index
|
||||
* transform taps + static dist fallback). Knows no harness concepts — every
|
||||
* feature surface (API bridge, plugin bundles, SSE) is a route some other
|
||||
* plugin registers. Web (browser) shape only — Electron loads dist over
|
||||
* file:// and carries fetch over an IPC bridge, not this server. This package
|
||||
* @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.
|
||||
*/
|
||||
|
||||
@@ -12,6 +11,7 @@ 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'
|
||||
@@ -35,6 +35,14 @@ export interface WebRoute {
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** One exact-path HTTP upgrade registration. */
|
||||
export interface WebUpgradeRoute {
|
||||
/** Absolute pathname, no trailing slash. */
|
||||
path: string
|
||||
/** Owns protocol negotiation and the upgraded socket after dispatch. */
|
||||
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). */
|
||||
export interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
@@ -61,6 +69,8 @@ export class HttpServerService extends Service {
|
||||
|
||||
private readonly exact = new Map<string, WebRoute>()
|
||||
private readonly prefixes = new Map<string, WebRoute>()
|
||||
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
|
||||
@@ -98,6 +108,20 @@ export class HttpServerService extends Service {
|
||||
return () => { table.delete(route.path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an exact-path HTTP upgrade route. Duplicate paths throw because
|
||||
* one socket can have only one protocol owner.
|
||||
* @param route - pathname and handler owning negotiation plus socket use.
|
||||
* @returns the disposer removing the route.
|
||||
*/
|
||||
registerUpgrade(route: WebUpgradeRoute): () => void {
|
||||
if (this.upgrades.has(route.path)) {
|
||||
throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
|
||||
}
|
||||
this.upgrades.set(route.path, route)
|
||||
return () => { this.upgrades.delete(route.path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied to every index response in
|
||||
* registration order.
|
||||
@@ -147,6 +171,40 @@ export class HttpServerService extends Service {
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
this.server.on('upgrade', (req, socket, head) => {
|
||||
const onError = (error: Error): void => {
|
||||
this.ctx.logger.warn(error)
|
||||
socket.destroy()
|
||||
}
|
||||
socket.on('error', onError)
|
||||
socket.once('close', () => {
|
||||
socket.off('error', onError)
|
||||
this.upgradedSockets.delete(socket)
|
||||
})
|
||||
let route: WebUpgradeRoute | undefined
|
||||
try {
|
||||
/* v8 ignore next -- node:http always sets url on server requests. */
|
||||
route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
if (route === undefined) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
this.upgradedSockets.add(socket)
|
||||
try {
|
||||
Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
||||
socket.destroy()
|
||||
})
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.once('error', reject)
|
||||
@@ -158,12 +216,19 @@ export class HttpServerService extends Service {
|
||||
})
|
||||
})
|
||||
|
||||
// close + closeAllConnections: held-open responses (SSE) never end on
|
||||
// their own; without the force-close, close() would hang teardown.
|
||||
this.ctx.effect(() => () => new Promise<void>((resolve) => {
|
||||
this.server.close(() => { resolve() })
|
||||
// Node does not include upgraded sockets in closeAllConnections(), so the
|
||||
// service tracks and destroys them as part of the same ownership boundary.
|
||||
this.ctx.effect(() => async () => {
|
||||
const serverClosed = new Promise<void>((resolve) => {
|
||||
this.server.close(() => { resolve() })
|
||||
})
|
||||
this.server.closeAllConnections()
|
||||
}), 'httpServer.listen')
|
||||
const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((resolve) => {
|
||||
socket.once('close', () => { resolve() })
|
||||
socket.destroy()
|
||||
}))
|
||||
await Promise.all([serverClosed, ...upgradedClosed])
|
||||
}, 'httpServer.listen')
|
||||
}
|
||||
|
||||
/** Longest-prefix-wins over the prefix table after an exact-table miss. */
|
||||
|
||||
@@ -15,7 +15,7 @@ export const name = 'host-webserver-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relation: route registrations and their disposers must stay
|
||||
* Owned relation: HTTP and upgrade route registrations and their disposers must stay
|
||||
* symmetric — after the owning fiber of a registered route unloads, the
|
||||
* route table must no longer answer for its path (a stale route would keep
|
||||
* serving a disposed plugin's handler). Checked on every fiber teardown
|
||||
@@ -26,7 +26,10 @@ export const inject = ['invariants']
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/plugin', () => {
|
||||
const server = ctx.get('httpServer') as
|
||||
| { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void }
|
||||
| {
|
||||
register(route: { kind: 'exact'; path: string; handler: () => void }): () => void
|
||||
registerUpgrade(route: { path: string; handler: () => void }): () => void
|
||||
}
|
||||
| undefined
|
||||
if (server === undefined) return // no webserver row in this composition
|
||||
// Register/dispose probe on a reserved path: if dispose leaves the route
|
||||
@@ -37,8 +40,11 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
try {
|
||||
server.register(probe)()
|
||||
server.register(probe)()
|
||||
const upgradeProbe = { path: '/__dsh_invariant_upgrade_probe__', handler: () => {} }
|
||||
server.registerUpgrade(upgradeProbe)()
|
||||
server.registerUpgrade(upgradeProbe)()
|
||||
} catch {
|
||||
fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged')
|
||||
fail('httpServer route disposer left a route registered — route tables and fiber lifecycles diverged')
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
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'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -72,6 +74,24 @@ async function request(port: number, path: string, init?: RequestInit): Promise<
|
||||
return { status: response.status, body: (await response.text()).slice(0, 80) }
|
||||
}
|
||||
|
||||
/** Open one raw upgrade request and return after the handler writes its response. */
|
||||
async function upgrade(port: number, path: string): Promise<ReturnType<typeof connect>> {
|
||||
const socket = connect(port, '127.0.0.1')
|
||||
await once(socket, 'connect')
|
||||
const response = once(socket, 'data')
|
||||
socket.write([
|
||||
`GET ${path} HTTP/1.1`,
|
||||
`Host: 127.0.0.1:${String(port)}`,
|
||||
'Connection: Upgrade',
|
||||
'Upgrade: dsh-test',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'))
|
||||
const [data] = await response as [Buffer]
|
||||
expect(String(data)).toContain('101 Switching Protocols')
|
||||
return socket
|
||||
}
|
||||
|
||||
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
|
||||
@@ -131,8 +151,51 @@ describe('real Loader composition', () => {
|
||||
expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
|
||||
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
|
||||
|
||||
// Teardown: fiber dispose closes the socket and severs held connections.
|
||||
// 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.
|
||||
let upgradedServerClosed = false
|
||||
const disposeUpgrade = server.registerUpgrade({
|
||||
path: '/events',
|
||||
handler: (_req, socket) => {
|
||||
socket.once('close', () => { upgradedServerClosed = true })
|
||||
socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n')
|
||||
},
|
||||
})
|
||||
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} }))
|
||||
.toThrow(/duplicate upgrade route/)
|
||||
const upgraded = await upgrade(port, '/events?stream=mux')
|
||||
disposeUpgrade()
|
||||
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} })).not.toThrow()
|
||||
|
||||
// The webserver contains raw-socket errors even before an upgrade handler
|
||||
// has installed its protocol implementation.
|
||||
server.registerUpgrade({
|
||||
path: '/upgrade-error',
|
||||
handler: async (_req, socket) => {
|
||||
await Promise.resolve()
|
||||
socket.destroy(new Error('test upgrade transport failure'))
|
||||
},
|
||||
})
|
||||
const failedUpgrade = connect(port, '127.0.0.1')
|
||||
failedUpgrade.on('error', () => { /* The server-side reset is the fixture outcome. */ })
|
||||
await once(failedUpgrade, 'connect')
|
||||
const failedUpgradeClosed = once(failedUpgrade, 'close')
|
||||
failedUpgrade.write([
|
||||
'GET /upgrade-error HTTP/1.1',
|
||||
`Host: 127.0.0.1:${String(port)}`,
|
||||
'Connection: Upgrade',
|
||||
'Upgrade: dsh-test',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'))
|
||||
await failedUpgradeClosed
|
||||
expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
|
||||
|
||||
// Teardown closes both ordinary and upgraded sockets before it resolves.
|
||||
await loaded.fiber.dispose()
|
||||
expect(upgradedServerClosed).toBe(true)
|
||||
upgraded.destroy()
|
||||
await expect(request(port, '/probe')).rejects.toThrow()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user