# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
94 lines
3.5 KiB
TypeScript
94 lines
3.5 KiB
TypeScript
/**
|
|
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
|
|
* web carrier; the fetch-shaped handler itself is transport-agnostic).
|
|
*/
|
|
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
|
/** Transport-independent request handler consumed by the Host HTTP bridge. */
|
|
export interface FetchHandler {
|
|
/**
|
|
* Handle one standard Fetch request.
|
|
* @param request - request produced by the active transport bridge.
|
|
* @returns complete or streaming Fetch response.
|
|
*/
|
|
fetch(request: Request): Promise<Response>
|
|
}
|
|
|
|
/**
|
|
* Bridge one node:http request to the fetch-shaped handler (client close
|
|
* aborts; SSE bodies stream out chunk by chunk).
|
|
* @param req - incoming node:http request (fully read before dispatch).
|
|
* @param res - node:http response the bridge writes and owns to completion.
|
|
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
|
* @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
|
|
*/
|
|
export async function bridge(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
apiHandler: FetchHandler,
|
|
maxRequestBodyBytes = 32 * 1024 * 1024,
|
|
): Promise<void> {
|
|
const abort = new AbortController()
|
|
// Client-disconnect detection MUST hang off the response, not the request:
|
|
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
|
// fully consumed (immediately for a bodyless GET), which would abort every SSE
|
|
// stream right after open. ServerResponse 'close' fires on connection teardown;
|
|
// writableEnded distinguishes a normal end() from the client going away.
|
|
res.on('close', () => {
|
|
if (!res.writableEnded) abort.abort()
|
|
})
|
|
const declaredLength = req.headers['content-length']
|
|
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
|
|
res.writeHead(413, { connection: 'close' })
|
|
res.end()
|
|
req.destroy()
|
|
return
|
|
}
|
|
const chunks: Buffer[] = []
|
|
let received = 0
|
|
for await (const chunk of req) {
|
|
const buffer = chunk as Buffer
|
|
received += buffer.byteLength
|
|
if (received > maxRequestBodyBytes) {
|
|
res.writeHead(413, { connection: 'close' })
|
|
res.end()
|
|
req.destroy()
|
|
return
|
|
}
|
|
chunks.push(buffer)
|
|
}
|
|
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
|
|
requests; the fields are only optional on the client-side IncomingMessage type */
|
|
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
|
|
method: req.method ?? 'GET',
|
|
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
|
|
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
|
|
signal: abort.signal,
|
|
})
|
|
const response = await apiHandler.fetch(request)
|
|
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
|
|
if (response.body === null) {
|
|
res.end()
|
|
return
|
|
}
|
|
for await (const chunk of response.body) {
|
|
// Backpressure: a false return means the socket buffer is full — wait for drain
|
|
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
|
|
// resolves so a mid-wait disconnect can't park this loop forever; the close
|
|
// handler above aborts the handler stream, which then ends the iteration.
|
|
if (!res.write(chunk)) {
|
|
await new Promise<void>((resolve) => {
|
|
const done = (): void => {
|
|
res.off('drain', done)
|
|
res.off('close', done)
|
|
resolve()
|
|
}
|
|
res.once('drain', done)
|
|
res.once('close', done)
|
|
})
|
|
}
|
|
}
|
|
res.end()
|
|
}
|