index.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. /** Host BFF entry and Loader shell for the Remote contribution assembly. */
  2. import { homedir } from 'node:os'
  3. import type { Context } from '@deepseek-ai/cordis'
  4. import type {
  5. TypertRemoteEventDispatch,
  6. TypertRemoteEventInvocation,
  7. TypertRemoteEventOutcome,
  8. TypertRemoteEventSource,
  9. } from '@deepseek-ai/dsh-api-gateway'
  10. import { Deque } from '@deepseek-ai/dsh-deque'
  11. import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
  12. import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
  13. import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
  14. // The owner packages' client-safe `./types` exports carry the cordis `Events`
  15. // declarations for every allowlisted event. Pulling them into this face is what
  16. // makes the shape assertion below judge real signatures rather than an empty
  17. // event vocabulary.
  18. import type {} from '@deepseek-ai/dsh-commands/types'
  19. import type {} from '@deepseek-ai/dsh-cordis-host-runner/types'
  20. import type {} from '@deepseek-ai/dsh-credentials/types'
  21. import type {} from '@deepseek-ai/dsh-llm/types'
  22. import type {} from '@deepseek-ai/dsh-agent-presets/types'
  23. import type {} from '@deepseek-ai/dsh-settings/types'
  24. import type {} from '@deepseek-ai/dsh-user-approval'
  25. import type {} from '@deepseek-ai/dsh-user-questions'
  26. export type {} from '@deepseek-ai/dsh-api-session-controller/types'
  27. export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
  28. export type { ApiRemoteForwardedEvent } from './types.ts'
  29. /** Required Host service: the Gateway owns the physical Remote stream mux. */
  30. export const inject = ['typertGateway']
  31. /** Host plugin body registering this application's selected Cordis event source. */
  32. export function apply(ctx: Context): void {
  33. ctx.effect(
  34. () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx), { home: homedir() }),
  35. 'api-remotes: forwarded Cordis event source',
  36. )
  37. }
  38. /** Create the sole queue and listener set consumed by the registered Gateway. */
  39. function remoteEventSource(ctx: Context): TypertRemoteEventSource {
  40. return (signal) => {
  41. const queue = new RemoteEventQueue()
  42. const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => {
  43. if (mode === 'emit') {
  44. return ctx.on(event as never, ((...args: unknown[]) => {
  45. queue.push({ event, args: assertJsonArgs(event, args) })
  46. }) as never)
  47. }
  48. return ctx.on(event as never, (function (
  49. this: unknown,
  50. request: object,
  51. next: () => unknown,
  52. ) {
  53. const subject = carrierKeyOf(this)
  54. if (subject === undefined) return next()
  55. const value = Reflect.get(subject, 'ctx') as unknown
  56. if (typeof value !== 'object' || value === null) {
  57. throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`)
  58. }
  59. return forwardWaterfall(
  60. queue,
  61. event,
  62. request,
  63. { value: value as Context, subject },
  64. next,
  65. )
  66. }) as never)
  67. })
  68. return queue.iterate(signal, () => {
  69. for (const dispose of disposers) dispose()
  70. })
  71. }
  72. }
  73. /** One pull-driven queue bridging synchronous Cordis listeners to an AsyncIterable. */
  74. class RemoteEventQueue {
  75. private readonly buffer = new Deque<TypertRemoteEventDispatch>()
  76. private waiter: (() => void) | undefined
  77. private done = false
  78. push(frame: TypertRemoteEventDispatch): boolean {
  79. if (this.done) return false
  80. this.buffer.pushBack(frame)
  81. this.waiter?.()
  82. return true
  83. }
  84. private end(reason: unknown): void {
  85. if (this.done) return
  86. this.done = true
  87. while (this.buffer.size > 0) {
  88. const dispatch = this.buffer.popFront() as TypertRemoteEventDispatch
  89. if ('context' in dispatch) dispatch.reject(reason)
  90. }
  91. this.waiter?.()
  92. }
  93. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<TypertRemoteEventDispatch> {
  94. const abort = (): void => { this.end(remoteEventSourceEndReason(signal)) }
  95. signal.addEventListener('abort', abort, { once: true })
  96. try {
  97. while (true) {
  98. if (this.done || signal.aborted) return
  99. while (this.buffer.size > 0) yield this.buffer.popFront() as TypertRemoteEventDispatch
  100. await new Promise<void>((resolve) => { this.waiter = resolve })
  101. this.waiter = undefined
  102. }
  103. } finally {
  104. signal.removeEventListener('abort', abort)
  105. this.end(remoteEventSourceEndReason(signal))
  106. cleanup()
  107. }
  108. }
  109. }
  110. /**
  111. * Normalize an event-source shutdown for pending Host waterfalls.
  112. * @param signal - source lifetime whose reason wins after cancellation.
  113. * @returns the cancellation reason or an unexpected-end failure.
  114. */
  115. function remoteEventSourceEndReason(signal: AbortSignal): unknown {
  116. if (signal.aborted) return signal.reason
  117. return new Error('api-remotes: forwarded Remote event source ended')
  118. }
  119. /** Bridge one Cordis waterfall listener through the Gateway-owned pending event. */
  120. function forwardWaterfall(
  121. queue: RemoteEventQueue,
  122. event: string,
  123. request: object,
  124. context: TypertRemoteEventInvocation['context'],
  125. next: () => unknown,
  126. ): Promise<unknown> {
  127. const settled = Promise.withResolvers<unknown>()
  128. const dispatch: TypertRemoteEventInvocation = {
  129. event,
  130. request,
  131. context,
  132. resolve: (outcome: TypertRemoteEventOutcome) => {
  133. if (outcome.kind === 'result') {
  134. settled.resolve(outcome.value)
  135. return
  136. }
  137. void Promise.resolve().then(next).then(settled.resolve, settled.reject)
  138. },
  139. reject: settled.reject,
  140. }
  141. if (!queue.push(dispatch)) void Promise.resolve().then(next).then(settled.resolve, settled.reject)
  142. return settled.promise
  143. }
  144. /** Reject an allowlisted event whose runtime arguments are not lossless JSON data. */
  145. function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] {
  146. for (const [index, arg] of args.entries()) {
  147. if (!isJsonValue(arg)) {
  148. throw new Error(`forwarded host event "${event}" argument ${String(index)} is not lossless JSON data`)
  149. }
  150. }
  151. return args as JsonValue[]
  152. }