Files
deepseek-harness/packages/client/connection/src/index.ts
T
07akioni 31b3f3bc44 Merge remote-tracking branch 'origin/master' into feat/electron
# Conflicts:
#	.agents/notes/archived/manifest.json
#	.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml
#	.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md
#	.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.zh.md
#	.agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.i18n.yaml
#	.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.i18n.yaml
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.zh.md
#	packages/boot/app-boot/README.i18n.yaml
#	packages/boot/app-boot/README.md
#	packages/boot/app-boot/README.zh.md
#	packages/client/connection/src/index.ts
#	packages/client/connection/tests/node-half.host.spec.ts
#	packages/shell/tool-pwsh-persistent/README.i18n.yaml
#	packages/shell/tool-pwsh-persistent/README.md
#	packages/shell/tool-pwsh-persistent/README.zh.md
#	pnpm-lock.yaml
#	tsconfig.host.json
2026-09-07 11:10:46 +08:00

144 lines
5.6 KiB
TypeScript

/** Host HTTP bridge for browser-client RPC. */
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-attachment'
import type {} from '@deepseek-ai/dsh-credentials'
// Activates the webServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH } from './api-path.ts'
import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
import { assertTrustedAuthority } from './api-request-trust.ts'
import { BrowserAuth } from './browser-auth.ts'
import { HostConnectionService } from './rpc-host.ts'
import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts'
export type {
ConnectionFetchMethod,
ConnectionFetchHandler,
ConnectionFetchRoute,
ConnectionIndexRequest,
ConnectionIndexResponse,
ConnectionRpcEndpointMatcher,
ConnectionRpcFailure,
ConnectionRpcHandler,
ConnectionRequestRejection,
ConnectionRpcResult,
ConnectionRequestBodyMode,
ConnectionTrustRequest,
ClientRequest,
HostConnectionHandle,
HostConnectionFetch,
HostConnectionRpc,
RpcMessage,
ServerResponse,
} from './rpc.ts'
export { RpcId, transportError } from './rpc.ts'
export {
clientRequestSchema,
rpcErrorSchema,
rpcIdSchema,
rpcMessageSchema,
rpcResultSchema,
serverResponseSchema,
} from './rpc-schema.ts'
export { HostConnectionService } from './rpc-host.ts'
export { API_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
const attachments = ctx.get('attachments')
if (attachments === undefined) return
const requiredImageBodyBytes = Math.ceil(
attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
if (maxRequestBodyBytes < requiredImageBodyBytes) {
throw new Error(
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
)
}
}
/** Services required before providing Connection. */
export const inject = ['credentials']
/** Browser authentication, request limits, and connection recovery configuration. */
export interface ConnectionConfig {
/** Browser recovery timing, injected into each served page. */
recovery?: ConnectionRecoveryConfig
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by; the Web runtime derives LAN IP literals from an active all-interface
* bind. An entry that is not a bare, canonical authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
cookieMaxAgeDays?: number
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
maxRequestBodyBytes?: number
}
export const Config: z<ConnectionConfig> = z.object({
recovery: ConnectionRecoveryConfigSchema.default({}),
trustedHosts: z.array(String).default([]),
cookieMaxAgeDays: z.natural().min(1).default(30),
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
})
/**
* Provides carrier-neutral RPC and Fetch registries. When `webServer` is
* present, the plugin also mounts the `/api` browser transport with Host/Origin
* checks and persistent browser authentication.
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
*/
export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {
const recovery = resolveConnectionConfig(config?.recovery)
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
assertImageBodyCapacity(ctx, maxRequestBodyBytes)
const connection = new HostConnectionService(
ctx,
trustedHosts,
await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays),
)
ctx.inject(['webServer'], (webCtx) => {
assertImageBodyCapacity(webCtx, maxRequestBodyBytes)
webCtx.on('webserver/index-inject', (table) => {
table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery })
})
const fetchHandler = connection.createSharedFetchHandler(API_PATH)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
const rejection = connection.requestRejection(req)
if (rejection !== undefined) {
res.writeHead(rejection)
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
return
}
await bridge(req, res, fetchHandler, maxRequestBodyBytes)
},
}
webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route')
})
ctx.inject(['attachments'], (attachmentCtx) => {
assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes)
})
}