index.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. /** Host HTTP bridge for browser-client RPC. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import z from '@deepseek-ai/schemastery'
  4. import type {} from '@deepseek-ai/dsh-attachment'
  5. import type {} from '@deepseek-ai/dsh-credentials'
  6. // Activates the webServer Context merge used below.
  7. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
  8. import { API_PATH } from './api-path.ts'
  9. import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
  10. import { assertTrustedAuthority } from './api-request-trust.ts'
  11. import { BrowserAuth } from './browser-auth.ts'
  12. import { HostConnectionService } from './rpc-host.ts'
  13. import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts'
  14. export type {
  15. ConnectionFetchMethod,
  16. ConnectionFetchHandler,
  17. ConnectionFetchRoute,
  18. ConnectionIndexRequest,
  19. ConnectionIndexResponse,
  20. ConnectionRpcEndpointMatcher,
  21. ConnectionRpcFailure,
  22. ConnectionRpcHandler,
  23. ConnectionRequestRejection,
  24. ConnectionRpcResult,
  25. ConnectionRequestBodyMode,
  26. ConnectionTrustRequest,
  27. ClientRequest,
  28. HostConnectionHandle,
  29. HostConnectionFetch,
  30. HostConnectionRpc,
  31. RpcMessage,
  32. ServerResponse,
  33. } from './rpc.ts'
  34. export { RpcId, transportError } from './rpc.ts'
  35. export {
  36. clientRequestSchema,
  37. rpcErrorSchema,
  38. rpcIdSchema,
  39. rpcMessageSchema,
  40. rpcResultSchema,
  41. serverResponseSchema,
  42. } from './rpc-schema.ts'
  43. export { HostConnectionService } from './rpc-host.ts'
  44. export { API_PATH } from './api-path.ts'
  45. /** Stable Cordis plugin name. */
  46. export const name = 'client-connection'
  47. /** Headroom for RPC JSON fields around aggregate base64 image payloads. */
  48. const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
  49. function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
  50. const attachments = ctx.get('attachments')
  51. if (attachments === undefined) return
  52. const requiredImageBodyBytes = Math.ceil(
  53. attachments.imageLimits.maxMessageImageBytes * 4 / 3,
  54. ) + REQUEST_ENVELOPE_HEADROOM_BYTES
  55. if (maxRequestBodyBytes < requiredImageBodyBytes) {
  56. throw new Error(
  57. `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
  58. + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
  59. )
  60. }
  61. }
  62. /** Services required before providing Connection. */
  63. export const inject = ['credentials']
  64. /** Browser authentication, request limits, and connection recovery configuration. */
  65. export interface ConnectionConfig {
  66. /** Browser recovery timing, injected into each served page. */
  67. recovery?: ConnectionRecoveryConfig
  68. /**
  69. * Authorities this deployment serves beyond loopback: exact `host:port`, or
  70. * port-less `host` matching any port. The /api trust fence refuses any
  71. * request whose Host is neither loopback nor listed here, so a
  72. * non-loopback (`0.0.0.0`) deployment must declare the names it is reached
  73. * by; the Web runtime derives LAN IP literals from an active all-interface
  74. * bind. An entry that is not a bare, canonical authority fails plugin load.
  75. */
  76. trustedHosts?: string[]
  77. /** Absolute browser-session lifetime in days. Default: 30. */
  78. cookieMaxAgeDays?: number
  79. /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
  80. maxRequestBodyBytes?: number
  81. }
  82. export const Config: z<ConnectionConfig> = z.object({
  83. recovery: ConnectionRecoveryConfigSchema.default({}),
  84. trustedHosts: z.array(String).default([]),
  85. cookieMaxAgeDays: z.natural().min(1).default(30),
  86. maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
  87. })
  88. /**
  89. * Provides carrier-neutral RPC and Fetch registries. When `webServer` is
  90. * present, the plugin also mounts the `/api` browser transport with Host/Origin
  91. * checks and persistent browser authentication.
  92. * @param ctx - Host plugin context.
  93. * @param config - resolved plugin config (schema defaults applied).
  94. */
  95. export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {
  96. const recovery = resolveConnectionConfig(config?.recovery)
  97. // The Loader resolves schema defaults; hand-built test contexts may pass none.
  98. const trustedHosts = config?.trustedHosts ?? []
  99. const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30
  100. const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
  101. // Config boundary: a malformed entry fails the load loudly here rather than
  102. // silently authorizing its hostname prefix at request time.
  103. for (const entry of trustedHosts) assertTrustedAuthority(entry)
  104. assertImageBodyCapacity(ctx, maxRequestBodyBytes)
  105. const connection = new HostConnectionService(
  106. ctx,
  107. trustedHosts,
  108. await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays),
  109. )
  110. ctx.inject(['webServer'], (webCtx) => {
  111. assertImageBodyCapacity(webCtx, maxRequestBodyBytes)
  112. webCtx.on('webserver/index-inject', (table) => {
  113. table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery })
  114. })
  115. const fetchHandler = connection.createSharedFetchHandler(API_PATH)
  116. const route: WebRoute = {
  117. kind: 'prefix',
  118. path: API_PATH,
  119. handler: async (req, res) => {
  120. const rejection = connection.requestRejection(req)
  121. if (rejection !== undefined) {
  122. res.writeHead(rejection)
  123. res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
  124. return
  125. }
  126. await bridge(req, res, fetchHandler, maxRequestBodyBytes)
  127. },
  128. }
  129. webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route')
  130. })
  131. ctx.inject(['attachments'], (attachmentCtx) => {
  132. assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes)
  133. })
  134. }