1
/** Host HTTP bridge for browser-client RPC. */2
import type { Context } from '@deepseek-ai/cordis'3
import type { IncomingMessage, ServerResponse } from 'node:http'4
import z from '@deepseek-ai/schemastery'5
import type {} from '@deepseek-ai/dsh-attachment'6
import type {} from '@deepseek-ai/dsh-credentials'7
// Activates the webServer Context merge used below.8
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'9
import { API_PATH } from './api-path.ts'10
import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'11
import { assertTrustedAuthority } from './api-request-trust.ts'12
import { BrowserAuth } from './browser-auth.ts'13
import { HostConnectionService } from './rpc-host.ts'14
import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts'16
export type {17
PeerAdmission,18
ConnectionFetchMethod,19
ConnectionFetchHandler,20
ConnectionFetchRoute,21
ConnectionIndexRequest,22
ConnectionIndexResponse,23
ConnectionRpcEndpointMatcher,24
ConnectionRpcAttachment,25
ConnectionRpcFailure,26
ConnectionRpcHandler,27
ConnectionRpcHandlerResult,28
ConnectionRequestRejection,29
ConnectionRpcResult,30
ConnectionRequestBodyMode,31
ConnectionTrustRequest,32
ClientRequest,33
HostConnectionHandle,34
HostConnectionFetch,35
HostConnectionRpc,36
RpcMessage,37
ServerResponse,38
} from './rpc.ts'39
export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol'40
export { RpcId, transportError } from './rpc.ts'41
export { OperatorPeer } from './operator-peer.ts'42
export {43
clientRequestSchema,44
rpcErrorSchema,45
rpcIdSchema,46
rpcMessageSchema,47
rpcResultSchema,48
serverResponseSchema,49
} from './rpc-schema.ts'50
export { HostConnectionService } from './rpc-host.ts'52
export { API_PATH } from './api-path.ts'54
/** Stable Cordis plugin name. */55
export const name = 'client-connection'57
declare module '@deepseek-ai/cordis' {58
interface Events {59
/**60
* Admit or wrap an authenticated shared API request, including body transfer.61
* Existing requests continue when a listener refuses subsequent requests.62
* @param request - Authenticated incoming HTTP request.63
* @param response - Response owned until the delegated bridge settles.64
* @param next - Delegate to the next listener or the shared API bridge.65
* @mode waterfall66
*/67
'connection/request'(request: IncomingMessage, response: ServerResponse, next: () => Promise<void>): Promise<void>68
}69
}71
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */72
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 102474
function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {75
const attachments = ctx.get('attachments')76
if (attachments === undefined) return77
const requiredImageBodyBytes = Math.ceil(78
attachments.imageLimits.maxMessageImageBytes * 4 / 3,79
) + REQUEST_ENVELOPE_HEADROOM_BYTES80
if (maxRequestBodyBytes < requiredImageBodyBytes) {81
throw new Error(82
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `83
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,84
)85
}86
}88
/** Services required before providing Connection. */89
export const inject = ['credentials']91
/** Browser authentication, request limits, and connection recovery configuration. */92
export interface ConnectionConfig {93
/** Browser recovery timing, injected into each served page. */94
recovery?: ConnectionRecoveryConfig95
/**96
* Authorities this deployment serves beyond loopback: exact `host:port`, or97
* port-less `host` matching any port. The /api trust fence refuses any98
* request whose Host is neither loopback nor listed here, so a99
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached100
* by; the Web runtime derives LAN IP literals from an active all-interface101
* bind. An entry that is not a bare, canonical authority fails plugin load.102
*/103
trustedHosts?: string[]104
/** Absolute browser-session lifetime in days. Default: 30. */105
cookieMaxAgeDays?: number106
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */107
maxRequestBodyBytes?: number108
}110
export const Config: z<ConnectionConfig> = z.object({111
recovery: ConnectionRecoveryConfigSchema.default({}),112
trustedHosts: z.array(String).default([]),113
cookieMaxAgeDays: z.natural().min(1).default(30),114
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),115
})117
/**118
* Provides carrier-neutral RPC and Fetch registries. When `webServer` is119
* present, the plugin also mounts the `/api` browser transport with Host/Origin120
* checks and persistent browser authentication.121
* @param ctx - Host plugin context.122
* @param config - resolved plugin config (schema defaults applied).123
*/124
export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {125
const recovery = resolveConnectionConfig(config?.recovery)126
// The Loader resolves schema defaults; hand-built test contexts may pass none.127
const trustedHosts = config?.trustedHosts ?? []128
const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30129
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES130
// Config boundary: a malformed entry fails the load loudly here rather than131
// silently authorizing its hostname prefix at request time.132
for (const entry of trustedHosts) assertTrustedAuthority(entry)133
assertImageBodyCapacity(ctx, maxRequestBodyBytes)134
const connection = new HostConnectionService(135
ctx,136
trustedHosts,137
await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays),138
)139
ctx.inject(['webServer'], (webCtx) => {140
assertImageBodyCapacity(webCtx, maxRequestBodyBytes)141
webCtx.on('webserver/index-inject', (table) => {142
table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery })143
})144
const fetchHandler = connection.createSharedFetchHandler(API_PATH)145
const route: WebRoute = {146
kind: 'prefix',147
path: API_PATH,148
handler: async (req, res) => {149
const admission = connection.admit(req)150
if ('rejection' in admission) {151
res.writeHead(admission.rejection)152
res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')153
return154
}155
await webCtx.waterfall('connection/request', req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes))156
},157
}158
webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route')159
})160
ctx.inject(['attachments'], (attachmentCtx) => {161
assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes)162
})163
}