index.ts 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209
  1. /**
  2. * Live Typert Remote dispatch over Cordis Services and registered providers.
  3. * Unary transport and response envelopes belong to Connection; live Remote
  4. * streams use the Gateway-owned WebSocket mux.
  5. * @module @deepseek-ai/dsh-api-gateway
  6. */
  7. import { randomUUID } from 'node:crypto'
  8. import { Context, Service, symbols } from '@deepseek-ai/cordis'
  9. import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
  10. import { Deque } from '@deepseek-ai/dsh-deque'
  11. import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
  12. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  13. import z from '@deepseek-ai/schemastery'
  14. export type { TypertGatewayFaultDetails } from './remote-error-codes.ts'
  15. import {
  16. RemoteError,
  17. remoteErrorOf,
  18. remoteMethods,
  19. type InvocationDescriptor,
  20. type InvocationParameterDescriptor,
  21. type TypertCodec,
  22. type TypertGatewayBinding,
  23. } from '@deepseek-ai/dsh-typert-protocol'
  24. import type {
  25. InvokeRemoteRequest,
  26. TypertGateway,
  27. TypertGatewayErrorCode,
  28. TypertGatewayWireStream,
  29. TypertRemoteEventDispatch,
  30. TypertRemoteEventFrame,
  31. TypertRemoteEventInvocation,
  32. TypertRemoteEventOutcome,
  33. TypertRemoteEventSource,
  34. } from './types.ts'
  35. import {
  36. RemoteStreamMuxServer,
  37. rejectRemoteStreamUpgrade,
  38. } from './stream-server.ts'
  39. import {
  40. REMOTE_EVENT_STREAM_ENDPOINT,
  41. REMOTE_EVENT_STREAM_READY,
  42. REMOTE_EVENT_RESULT_ENDPOINT,
  43. REMOTE_STREAM_MUX_PATH,
  44. isRemoteEventAgentId,
  45. isRemoteJsonValue,
  46. parseRemoteEventResult,
  47. projectRemoteEventRequest,
  48. restoreRemoteEventRejection,
  49. type RemoteEventCancellationFrame,
  50. type RemoteEventClientId,
  51. type RemoteEventEmitFrame,
  52. type RemoteEventHostInfo,
  53. type RemoteEventId,
  54. type RemoteEventInvocationFrame,
  55. type RemoteEventReadyFrame,
  56. type RemoteStreamFailure,
  57. } from './stream-protocol.ts'
  58. export type {
  59. InvokeRemoteRequest,
  60. TypertGateway,
  61. TypertGatewayErrorCode,
  62. TypertGatewayWireStream,
  63. TypertRemoteEventContext,
  64. TypertRemoteEventDispatch,
  65. TypertRemoteEventFrame,
  66. TypertRemoteEventInvocation,
  67. TypertRemoteEventOutcome,
  68. TypertRemoteEventSource,
  69. } from './types.ts'
  70. export type { RemoteEventHostInfo } from './stream-protocol.ts'
  71. interface GatewayErrorOptions {
  72. readonly cause?: unknown
  73. readonly field?: string
  74. }
  75. interface ResolvedBinding {
  76. readonly binding: TypertGatewayBinding
  77. readonly original: object
  78. }
  79. interface PreparedInvocation {
  80. readonly endpoint: string
  81. readonly descriptor: InvocationDescriptor
  82. readonly receiver: object
  83. readonly args: readonly unknown[]
  84. readonly method: (...args: never[]) => unknown
  85. }
  86. interface RegisteredRemoteEventSource {
  87. readonly lifetime: AbortController
  88. readonly done: Promise<void>
  89. readonly host: RemoteEventHostInfo
  90. }
  91. interface RemoteEventClient {
  92. readonly id: RemoteEventClientId
  93. readonly queue: RemoteEventQueue
  94. readonly deliveries: Map<RemoteEventId, PendingRemoteEvent>
  95. }
  96. interface PendingRemoteEvent {
  97. readonly id: RemoteEventId
  98. readonly source: TypertRemoteEventInvocation
  99. readonly frame: RemoteEventInvocationFrame
  100. readonly deliveries: Set<RemoteEventClient>
  101. releaseContext: () => void
  102. releaseSignal: () => void
  103. }
  104. type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
  105. type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
  106. const NEVER_ABORTED_SIGNAL = new AbortController().signal
  107. const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000
  108. /** Gateway transport configuration. */
  109. export interface Config {
  110. /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */
  111. readonly websocketHeartbeatIntervalMs?: number
  112. }
  113. interface ResolvedConfig extends Config {
  114. readonly websocketHeartbeatIntervalMs: number
  115. }
  116. /**
  117. * Dispatch failure produced outside the invoked business method. Rides the
  118. * shared Remote failure vocabulary, so its code crosses the wire instead of
  119. * folding to `internal`.
  120. */
  121. export class TypertGatewayError extends RemoteError<TypertGatewayErrorCode> {
  122. /** Canonical `<namespace>/<method>` endpoint. */
  123. readonly endpoint: string
  124. /** Affected wire field when the failure is field-specific. */
  125. readonly field: string | undefined
  126. /**
  127. * Construct a Gateway failure without embedding boundary values in its message.
  128. * @param code - stable failure category.
  129. * @param endpoint - canonical Remote endpoint.
  130. * @param message - correction-oriented diagnostic without sensitive values.
  131. * @param options - optional field and contained cause.
  132. */
  133. constructor(
  134. code: TypertGatewayErrorCode,
  135. endpoint: string,
  136. message: string,
  137. options: GatewayErrorOptions = {},
  138. ) {
  139. super(
  140. code,
  141. `typert gateway: ${endpoint}: ${message}`,
  142. { endpoint, ...options.field === undefined ? {} : { field: options.field } },
  143. options.cause === undefined ? undefined : { cause: options.cause },
  144. )
  145. this.name = 'TypertGatewayError'
  146. this.endpoint = endpoint
  147. this.field = options.field
  148. }
  149. }
  150. /**
  151. * Resolve strict generated definitions or conservative SRC markers against
  152. * current Cordis Services and Typert providers.
  153. * @typert service typertGateway
  154. */
  155. export class TypertGatewayService extends Service implements TypertGateway {
  156. static inject = ['typert']
  157. static Config: z<Config> = z.object({
  158. websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS)
  159. .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS),
  160. })
  161. /** Carrier adapter shared by the WebSocket mux and local Host transports. */
  162. readonly wireStream: TypertGatewayWireStream = {
  163. open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
  164. failure: error => rpcError(error),
  165. }
  166. private srcClaims: ReadonlySet<string> | undefined
  167. private remoteEvents: RegisteredRemoteEventSource | undefined
  168. private readonly remoteEventClients = new Map<RemoteEventClientId, RemoteEventClient>()
  169. private readonly pendingRemoteEvents = new Map<RemoteEventId, PendingRemoteEvent>()
  170. /**
  171. * Register the Gateway against the active Typert registry.
  172. * @param ctx - owning Host Context with Typert registry access.
  173. * @param config - validated Gateway transport configuration.
  174. */
  175. constructor(ctx: Context, config: Config) {
  176. super(ctx, 'typertGateway')
  177. const resolved = config as ResolvedConfig
  178. ctx.on('internal/service', () => {
  179. this.srcClaims = undefined
  180. })
  181. ctx.inject(['connection'], (connectionCtx) => {
  182. connectionCtx.connection.rpc.intercept(
  183. '/api',
  184. endpoint => this.claimsEndpoint(endpoint),
  185. (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
  186. )
  187. })
  188. ctx.inject(['connection', 'webServer'], (webCtx) => {
  189. const mux = new RemoteStreamMuxServer(
  190. (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
  191. this.wireStream.failure,
  192. resolved.websocketHeartbeatIntervalMs,
  193. )
  194. webCtx.effect(() => {
  195. const route: WebUpgradeRoute = {
  196. path: REMOTE_STREAM_MUX_PATH,
  197. handler: (req, socket, head) => {
  198. const rejection = webCtx.connection.requestRejection(req)
  199. if (rejection !== undefined) {
  200. rejectRemoteStreamUpgrade(socket, rejection)
  201. return
  202. }
  203. mux.handleUpgrade(req, socket, head)
  204. },
  205. }
  206. const unregister = webCtx.webServer.registerUpgrade(route)
  207. return async () => {
  208. unregister()
  209. await mux.close()
  210. }
  211. }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`)
  212. })
  213. }
  214. /**
  215. * Register the sole application-selected forwarded-event source.
  216. * @param source - stream factory installed by the Remote assembly.
  217. * @param host - stable Host facts included in each Client generation's opening frame.
  218. * @returns disposer removing this source and cancelling its active streams.
  219. */
  220. registerRemoteEvents(
  221. source: TypertRemoteEventSource,
  222. host: RemoteEventHostInfo,
  223. ): () => Promise<void> {
  224. if (this.remoteEvents !== undefined) {
  225. throw new Error('typert gateway: forwarded Remote event source is already registered')
  226. }
  227. const lifetime = new AbortController()
  228. const stream = source(lifetime.signal)
  229. const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => {
  230. if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return
  231. this.closeRemoteEvents(error)
  232. this.remoteEvents = undefined
  233. lifetime.abort(error)
  234. })
  235. const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } }
  236. this.remoteEvents = registration
  237. return async () => {
  238. if (this.remoteEvents === registration) {
  239. this.remoteEvents = undefined
  240. const error = new Error('typert gateway: forwarded Remote event source was removed')
  241. registration.lifetime.abort(error)
  242. this.closeRemoteEvents(error)
  243. }
  244. await registration.done
  245. }
  246. }
  247. private claimsEndpoint(endpoint: string): boolean {
  248. if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true
  249. const segments = endpoint.split('/')
  250. if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
  251. if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
  252. this.srcClaims ??= this.collectSrcClaims()
  253. return this.srcClaims.has(endpoint)
  254. }
  255. private collectSrcClaims(): ReadonlySet<string> {
  256. const claims = new Set<string>()
  257. for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
  258. if (definition.type !== 'service') continue
  259. const receiver = this.ctx.get(serviceKey) as unknown
  260. if (!isObject(receiver)) continue
  261. const original = originalOf(receiver)
  262. const binding = Reflect.get(original, 'typertRemote') as unknown
  263. if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue
  264. const namespace = Reflect.get(binding, 'namespace') as string
  265. for (const candidate of remoteMethods(original)) {
  266. claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method))
  267. }
  268. }
  269. return claims
  270. }
  271. /**
  272. * Invoke one live Remote method through strict generated reflection or SRC markers.
  273. * @param request - decoded endpoint and exact named wire arguments.
  274. * @returns the business result without output decoding.
  275. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
  276. */
  277. async invoke(request: InvokeRemoteRequest): Promise<unknown> {
  278. const prepared = await this.prepareInvocation(request)
  279. if (prepared.descriptor.mode === 'stream') {
  280. throw new TypertGatewayError(
  281. 'gateway/signature-invalid',
  282. prepared.endpoint,
  283. 'stream Remote methods must be opened through the stream carrier',
  284. )
  285. }
  286. try {
  287. return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
  288. } catch (error) {
  289. if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error)
  290. throw error
  291. }
  292. }
  293. /**
  294. * Open one live stream Remote method without assuming a physical carrier.
  295. * @param request - decoded endpoint and named wire arguments.
  296. * @returns a cancellation-aware iterable over the business results.
  297. */
  298. async stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>> {
  299. const prepared = await this.prepareInvocation(request)
  300. if (prepared.descriptor.mode !== 'stream') {
  301. throw new TypertGatewayError(
  302. 'gateway/signature-invalid',
  303. prepared.endpoint,
  304. 'unary Remote methods cannot be opened through the stream carrier',
  305. )
  306. }
  307. let source: unknown
  308. try {
  309. source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
  310. } catch (error) {
  311. if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error)
  312. throw error
  313. }
  314. if (!isIterable(source)) {
  315. throw new TypertGatewayError(
  316. 'gateway/result-invalid',
  317. prepared.endpoint,
  318. 'stream Remote method did not return Iterable or AsyncIterable',
  319. { field: 'result' },
  320. )
  321. }
  322. return cancellableStream(
  323. source,
  324. prepared.endpoint,
  325. request.signal ?? NEVER_ABORTED_SIGNAL,
  326. )
  327. }
  328. private async dispatchRpc(
  329. endpoint: string,
  330. payload: unknown,
  331. signal: AbortSignal,
  332. ): Promise<ConnectionRpcResult> {
  333. if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) {
  334. try {
  335. const result = parseRemoteEventResultPayload(payload)
  336. const client = this.remoteEventClients.get(result.clientId)
  337. if (client === undefined) {
  338. throw new Error('typert gateway: Remote event result identifies no active event stream')
  339. }
  340. this.receiveRemoteEventResult(client, result)
  341. return { ok: true, value: undefined }
  342. } catch (error) {
  343. return rpcFailure(error)
  344. }
  345. }
  346. return this.invokeRpc(endpoint, payload, signal)
  347. }
  348. private async openWireStream(
  349. endpoint: string,
  350. payload: unknown,
  351. signal: AbortSignal,
  352. ): Promise<AsyncIterable<unknown>> {
  353. if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) {
  354. return this.openRemoteEvents(payload, signal)
  355. }
  356. return this.stream(remoteRequest(endpoint, payload, signal))
  357. }
  358. private async *openRemoteEvents(
  359. payload: unknown,
  360. signal: AbortSignal,
  361. ): AsyncGenerator<
  362. RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame
  363. | RemoteEventReadyFrame
  364. > {
  365. if (!isObject(payload)
  366. || !isPlainObject(payload)
  367. || Reflect.ownKeys(payload).length !== 1
  368. || !Object.hasOwn(payload, 'args')
  369. || !isObject(payload.args)
  370. || !isPlainObject(payload.args)
  371. || Reflect.ownKeys(payload.args).length !== 0) {
  372. throw new TypertGatewayError(
  373. 'gateway/arguments-invalid',
  374. REMOTE_EVENT_STREAM_ENDPOINT,
  375. 'forwarded Remote event stream requires an empty args object',
  376. )
  377. }
  378. const registration = this.remoteEvents
  379. if (registration === undefined) {
  380. throw new TypertGatewayError(
  381. 'gateway/service-unavailable',
  382. REMOTE_EVENT_STREAM_ENDPOINT,
  383. 'forwarded Remote event source is unavailable',
  384. )
  385. }
  386. const lifetime = AbortSignal.any([signal, registration.lifetime.signal])
  387. let clientId = randomUUID() as RemoteEventClientId
  388. while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId
  389. const client: RemoteEventClient = {
  390. id: clientId,
  391. queue: new RemoteEventQueue(),
  392. deliveries: new Map(),
  393. }
  394. this.remoteEventClients.set(clientId, client)
  395. for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client)
  396. try {
  397. yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host }
  398. yield* client.queue.iterate(lifetime)
  399. } finally {
  400. this.removeRemoteEventClient(client)
  401. }
  402. }
  403. private async consumeRemoteEvents(
  404. source: AsyncIterable<TypertRemoteEventDispatch>,
  405. signal: AbortSignal,
  406. ): Promise<void> {
  407. for await (const dispatch of source) {
  408. if (signal.aborted) {
  409. if ('context' in dispatch) dispatch.reject(signal.reason)
  410. return
  411. }
  412. if ('context' in dispatch) this.startRemoteEvent(dispatch)
  413. else this.broadcastRemoteEvent(dispatch)
  414. }
  415. if (!signal.aborted) {
  416. throw new Error('typert gateway: forwarded Remote event source ended unexpectedly')
  417. }
  418. }
  419. private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void {
  420. assertRemoteEventFrame(frame)
  421. const wire: RemoteEventEmitFrame = {
  422. type: 'emit',
  423. event: frame.event,
  424. args: frame.args,
  425. }
  426. for (const client of this.remoteEventClients.values()) client.queue.push(wire)
  427. }
  428. private startRemoteEvent(source: TypertRemoteEventInvocation): void {
  429. try {
  430. assertRemoteEventName(source)
  431. const context = this.ctx.typert.contexts.identifyHost(source.context.value)
  432. if (context === undefined) {
  433. source.resolve({ kind: 'next' })
  434. return
  435. }
  436. if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) {
  437. throw new TypeError(
  438. 'typert gateway: scoped Remote events require a non-empty Agent identity',
  439. )
  440. }
  441. const projected = projectRemoteEventRequest(source.request, source.context.subject)
  442. let id = randomUUID() as RemoteEventId
  443. while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId
  444. let releaseContext: () => void
  445. try {
  446. const dispose = source.context.value.effect(
  447. () => () => {
  448. this.cancelRemoteEvent(
  449. pending,
  450. new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`),
  451. )
  452. },
  453. `api-gateway: Remote event ${JSON.stringify(source.event)}`,
  454. )
  455. releaseContext = () => { void dispose() }
  456. } catch {
  457. source.resolve({ kind: 'next' })
  458. return
  459. }
  460. const signals = new Set(projected.signal === undefined ? [] : [projected.signal])
  461. const abort = (): void => {
  462. const reason = [...signals].find(signal => signal.aborted)?.reason as unknown
  463. this.cancelRemoteEvent(pending, reason instanceof Error
  464. ? reason
  465. : new Error('typert gateway: Remote event was cancelled', { cause: reason }))
  466. }
  467. const pending: PendingRemoteEvent = {
  468. id,
  469. source,
  470. frame: {
  471. type: 'waterfall',
  472. event: source.event,
  473. eventId: id,
  474. agentId: context.identity,
  475. request: projected.request,
  476. },
  477. deliveries: new Set(),
  478. releaseContext,
  479. releaseSignal: () => {
  480. for (const signal of signals) signal.removeEventListener('abort', abort)
  481. },
  482. }
  483. this.pendingRemoteEvents.set(id, pending)
  484. for (const signal of signals) signal.addEventListener('abort', abort, { once: true })
  485. if ([...signals].some(signal => signal.aborted)) abort()
  486. else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client)
  487. } catch (error) {
  488. source.reject(error)
  489. }
  490. }
  491. private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void {
  492. pending.deliveries.add(client)
  493. client.deliveries.set(pending.id, pending)
  494. client.queue.push(pending.frame)
  495. }
  496. private receiveRemoteEventResult(
  497. client: RemoteEventClient,
  498. result: ReturnType<typeof parseRemoteEventResult>,
  499. ): void {
  500. const pending = this.pendingRemoteEvents.get(result.eventId)
  501. // Settlement and Client replacement may race the result request. Results
  502. // from a completed event or a superseded delivery are idempotent no-ops.
  503. if (pending === undefined || !pending.deliveries.has(client)) return
  504. this.removeRemoteEventDelivery(pending, client)
  505. if (result.outcome.kind === 'result') {
  506. this.settleRemoteEvent(pending, {
  507. kind: 'result',
  508. value: result.outcome.value,
  509. })
  510. } else if (result.outcome.kind === 'rejected') {
  511. this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error))
  512. } else if (pending.deliveries.size === 0) {
  513. this.settleRemoteEvent(pending, { kind: 'next' })
  514. }
  515. }
  516. private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void {
  517. pending.deliveries.delete(client)
  518. client.deliveries.delete(pending.id)
  519. }
  520. private removeRemoteEventClient(client: RemoteEventClient): void {
  521. this.remoteEventClients.delete(client.id)
  522. for (const pending of [...client.deliveries.values()]) this.removeRemoteEventDelivery(pending, client)
  523. client.queue.end()
  524. }
  525. private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void {
  526. this.finishRemoteEvent(pending)
  527. pending.source.resolve(outcome)
  528. }
  529. private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void {
  530. if (this.pendingRemoteEvents.get(pending.id) !== pending) return
  531. this.finishRemoteEvent(pending)
  532. pending.source.reject(reason)
  533. }
  534. private finishRemoteEvent(pending: PendingRemoteEvent): void {
  535. this.pendingRemoteEvents.delete(pending.id)
  536. pending.releaseSignal()
  537. pending.releaseContext()
  538. const clients = new Set(pending.deliveries)
  539. for (const client of clients) this.removeRemoteEventDelivery(pending, client)
  540. const cancellation: RemoteEventCancellationFrame = {
  541. type: 'cancel',
  542. eventId: pending.id,
  543. }
  544. for (const client of clients) client.queue.push(cancellation)
  545. }
  546. private closeRemoteEvents(reason: unknown): void {
  547. for (const pending of [...this.pendingRemoteEvents.values()]) {
  548. this.cancelRemoteEvent(pending, reason)
  549. }
  550. for (const client of [...this.remoteEventClients.values()]) client.queue.end()
  551. }
  552. private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
  553. try {
  554. const value = await this.invoke(remoteRequest(endpoint, payload, signal))
  555. // A void or explicitly absent business result carries no `value` field;
  556. // JSON has no `undefined`, and the envelope's optional slot is the one
  557. // representation of absence that both args and results already use.
  558. return { ok: true, value }
  559. } catch (error) {
  560. return rpcFailure(error)
  561. }
  562. }
  563. private async prepareInvocation(request: InvokeRemoteRequest): Promise<PreparedInvocation> {
  564. const endpoint = endpointOf(request.namespace, request.method)
  565. const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
  566. assertExactArguments(request.args, descriptor, endpoint)
  567. const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
  568. const receiver = receiverContext.get(descriptor.service) as unknown
  569. if (!isObject(receiver)) {
  570. throw new TypertGatewayError(
  571. 'gateway/service-unavailable',
  572. endpoint,
  573. `active Service ${JSON.stringify(descriptor.service)} is unavailable`,
  574. )
  575. }
  576. validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
  577. const args = await Promise.all(descriptor.parameters.map(parameter =>
  578. this.resolveParameter(parameter, request.args, endpoint)))
  579. if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
  580. const implementation = descriptor.implementation ?? descriptor.method
  581. const method = Reflect.get(receiver, implementation) as unknown
  582. if (typeof method !== 'function') {
  583. throw new TypertGatewayError(
  584. 'gateway/method-unavailable',
  585. endpoint,
  586. `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
  587. )
  588. }
  589. return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown }
  590. }
  591. private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
  592. const strict = this.ctx.typert.local.get(endpoint)
  593. if (strict !== undefined) return strict
  594. if (this.ctx.typert.local.hasSeen(endpoint)) {
  595. throw new TypertGatewayError(
  596. 'gateway/definition-unavailable',
  597. endpoint,
  598. 'its strict definition was withdrawn and SRC fallback is forbidden',
  599. )
  600. }
  601. return this.resolveSrcDescriptor(namespace, method, endpoint)
  602. }
  603. private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
  604. const candidates: InvocationDescriptor[] = []
  605. for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
  606. if (definition.type !== 'service') continue
  607. const receiver = this.ctx.get(serviceKey) as unknown
  608. if (!isObject(receiver)) continue
  609. const original = originalOf(receiver)
  610. const value = Reflect.get(original, 'typertRemote') as unknown
  611. if (value === undefined) continue
  612. const binding = readBinding(value, original, serviceKey, endpoint)
  613. if (binding.namespace !== namespace) continue
  614. const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method)
  615. if (marker === undefined) continue
  616. candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
  617. }
  618. if (candidates.length === 0) {
  619. throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
  620. }
  621. if (candidates.length > 1) {
  622. throw new TypertGatewayError(
  623. 'gateway/ambiguous-endpoint',
  624. endpoint,
  625. `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
  626. )
  627. }
  628. return candidates[0] as InvocationDescriptor
  629. }
  630. private srcDescriptor(
  631. binding: TypertGatewayBinding,
  632. marker: ReturnType<typeof remoteMethods>[number],
  633. method: string,
  634. endpoint: string,
  635. ): InvocationDescriptor {
  636. const names = methodParameterNames(binding.service, marker.method, endpoint)
  637. const signalIndex = names.indexOf('signal')
  638. if (signalIndex >= 0 && signalIndex !== names.length - 1) {
  639. throw new TypertGatewayError(
  640. 'gateway/signature-invalid',
  641. endpoint,
  642. 'SRC cancellation parameter signal must be the final parameter',
  643. { field: 'signal' },
  644. )
  645. }
  646. const cancellation = signalIndex >= 0
  647. ? { parameter: 'signal' as const }
  648. : undefined
  649. const businessNames = cancellation === undefined ? names : names.slice(0, -1)
  650. const parameters: InvocationParameterDescriptor[] = []
  651. const wires = new Set<string>()
  652. for (const name of businessNames) {
  653. const matches = this.ctx.typert.lookups.definitions()
  654. .filter(definition => definition.parameter === name)
  655. if (matches.length > 1) {
  656. throw new TypertGatewayError(
  657. 'gateway/signature-invalid',
  658. endpoint,
  659. `parameter ${JSON.stringify(name)} matches multiple lookup providers`,
  660. { field: name },
  661. )
  662. }
  663. const match = matches[0]
  664. const parameter: InvocationParameterDescriptor = match === undefined
  665. ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
  666. : {
  667. name,
  668. wire: match.wire,
  669. source: 'lookup',
  670. lookup: match.key,
  671. codec: { mode: 'src-json' },
  672. }
  673. if (wires.has(parameter.wire)) {
  674. throw new TypertGatewayError(
  675. 'gateway/signature-invalid',
  676. endpoint,
  677. `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
  678. { field: parameter.wire },
  679. )
  680. }
  681. wires.add(parameter.wire)
  682. parameters.push(parameter)
  683. }
  684. let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' }
  685. if (marker.invocation.kind === 'context') {
  686. const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
  687. if (provider === undefined) {
  688. throw new TypertGatewayError(
  689. 'gateway/context-unavailable',
  690. endpoint,
  691. `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
  692. )
  693. }
  694. if (wires.has(provider.wire)) {
  695. throw new TypertGatewayError(
  696. 'gateway/signature-invalid',
  697. endpoint,
  698. `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
  699. { field: provider.wire },
  700. )
  701. }
  702. receiver = {
  703. kind: 'context',
  704. context: marker.invocation.context,
  705. wire: provider.wire,
  706. codec: { mode: 'src-json' },
  707. }
  708. }
  709. return {
  710. id: `src:${binding.serviceKey}#${endpoint}`,
  711. service: binding.serviceKey,
  712. namespace: binding.namespace,
  713. method,
  714. ...(marker.method === method ? {} : { implementation: marker.method }),
  715. ...(marker.mode === undefined ? {} : { mode: marker.mode }),
  716. invocation: receiver,
  717. parameters,
  718. ...(cancellation === undefined ? {} : { cancellation }),
  719. result: { mode: 'src-json' },
  720. }
  721. }
  722. private async resolveReceiverContext(
  723. descriptor: InvocationDescriptor,
  724. args: Readonly<Record<string, unknown>>,
  725. endpoint: string,
  726. ): Promise<Context> {
  727. if (descriptor.invocation.kind === 'direct') return this.ctx
  728. const invocation = descriptor.invocation
  729. const provider = this.ctx.typert.contexts.getHost(invocation.context)
  730. if (provider === undefined) {
  731. throw new TypertGatewayError(
  732. 'gateway/context-unavailable',
  733. endpoint,
  734. `Context provider ${JSON.stringify(invocation.context)} is unavailable`,
  735. )
  736. }
  737. if (provider.wire !== invocation.wire
  738. || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
  739. throw new TypertGatewayError(
  740. 'gateway/provider-mismatch',
  741. endpoint,
  742. `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
  743. { field: invocation.wire },
  744. )
  745. }
  746. const identity = decode(invocation.codec, args[invocation.wire], endpoint, invocation.wire)
  747. let context: Context | undefined
  748. try {
  749. context = await provider.resolve(identity)
  750. } catch (cause) {
  751. if (remoteErrorOf(cause) !== undefined) throw cause
  752. throw new TypertGatewayError(
  753. 'gateway/context-failed',
  754. endpoint,
  755. `Context provider ${JSON.stringify(invocation.context)} failed`,
  756. { cause, field: invocation.wire },
  757. )
  758. }
  759. if (context === undefined) {
  760. throw new TypertGatewayError(
  761. 'gateway/context-not-found',
  762. endpoint,
  763. `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
  764. { field: invocation.wire },
  765. )
  766. }
  767. return context
  768. }
  769. private async resolveParameter(
  770. parameter: InvocationParameterDescriptor,
  771. args: Readonly<Record<string, unknown>>,
  772. endpoint: string,
  773. ): Promise<unknown> {
  774. // An absent field reached assertExactArguments' allowance, so this parameter
  775. // takes undefined; a present-but-undefined field is not JSON-safe input and
  776. // still fails decode. Lookup ids are never omissible, so absence here only
  777. // ever belongs to a json parameter.
  778. if (!Object.hasOwn(args, parameter.wire)) return undefined
  779. const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire)
  780. if (parameter.source === 'json') return value
  781. const key = parameter.lookup
  782. /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
  783. if (key === undefined) {
  784. throw new TypertGatewayError(
  785. 'gateway/lookup-unavailable',
  786. endpoint,
  787. `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
  788. { field: parameter.wire },
  789. )
  790. }
  791. const provider = this.ctx.typert.lookups.get(key)
  792. if (provider === undefined) {
  793. throw new TypertGatewayError(
  794. 'gateway/lookup-unavailable',
  795. endpoint,
  796. `lookup provider ${JSON.stringify(key)} is unavailable`,
  797. { field: parameter.wire },
  798. )
  799. }
  800. if (provider.wire !== parameter.wire
  801. || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
  802. throw new TypertGatewayError(
  803. 'gateway/provider-mismatch',
  804. endpoint,
  805. `lookup provider ${JSON.stringify(key)} does not match its strict definition`,
  806. { field: parameter.wire },
  807. )
  808. }
  809. let resolved: unknown
  810. try {
  811. resolved = await provider.resolve(value)
  812. } catch (cause) {
  813. if (remoteErrorOf(cause) !== undefined) throw cause
  814. throw new TypertGatewayError(
  815. 'gateway/lookup-failed',
  816. endpoint,
  817. `lookup provider ${JSON.stringify(key)} failed`,
  818. { cause, field: parameter.wire },
  819. )
  820. }
  821. if (resolved === undefined) {
  822. throw new TypertGatewayError(
  823. 'gateway/lookup-not-found',
  824. endpoint,
  825. `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
  826. { field: parameter.wire },
  827. )
  828. }
  829. return resolved
  830. }
  831. }
  832. type RemoteEventWireFrame =
  833. | RemoteEventEmitFrame
  834. | RemoteEventInvocationFrame
  835. | RemoteEventCancellationFrame
  836. /** Pull-driven queue owned by one connected Client event generation. */
  837. class RemoteEventQueue {
  838. private readonly frames = new Deque<RemoteEventWireFrame>()
  839. private waiter: (() => void) | undefined
  840. private closed = false
  841. push(frame: RemoteEventWireFrame): void {
  842. if (this.closed) return
  843. this.frames.pushBack(frame)
  844. this.waiter?.()
  845. }
  846. end(): void {
  847. if (this.closed) return
  848. this.closed = true
  849. this.waiter?.()
  850. }
  851. async *iterate(signal: AbortSignal): AsyncGenerator<RemoteEventWireFrame> {
  852. const abort = (): void => { this.end() }
  853. signal.addEventListener('abort', abort, { once: true })
  854. try {
  855. while (true) {
  856. while (this.frames.size > 0) yield this.frames.popFront() as RemoteEventWireFrame
  857. if (this.closed || signal.aborted) return
  858. await new Promise<void>((resolve) => { this.waiter = resolve })
  859. this.waiter = undefined
  860. }
  861. } finally {
  862. signal.removeEventListener('abort', abort)
  863. }
  864. }
  865. }
  866. function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void {
  867. assertRemoteEventName(frame)
  868. if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) {
  869. throw new TypeError(`typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`)
  870. }
  871. }
  872. function assertRemoteEventName(frame: { readonly event: unknown }): void {
  873. if (typeof frame.event !== 'string' || frame.event.length === 0) {
  874. throw new TypeError('typert gateway: Remote event name must be a nonempty string')
  875. }
  876. }
  877. function parseRemoteEventResultPayload(payload: unknown): ReturnType<typeof parseRemoteEventResult> {
  878. if (!isObject(payload)
  879. || !isPlainObject(payload)
  880. || Reflect.ownKeys(payload).length !== 1
  881. || !Object.hasOwn(payload, 'args')) {
  882. throw new Error('typert gateway: Remote event result requires exactly one plain-object args field')
  883. }
  884. return parseRemoteEventResult(payload.args)
  885. }
  886. function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal): InvokeRemoteRequest {
  887. const segments = endpoint.split('/')
  888. if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
  889. throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
  890. }
  891. const [namespace, method] = segments as [string, string]
  892. if (!isObject(payload)
  893. || !isPlainObject(payload)
  894. || Reflect.ownKeys(payload).length !== 1
  895. || !Object.hasOwn(payload, 'args')
  896. || !isObject(payload.args)
  897. || !isPlainObject(payload.args)) {
  898. throw new Error('Remote payload must contain exactly one plain-object args field')
  899. }
  900. return { namespace, method, args: payload.args, signal }
  901. }
  902. function isIterable(value: unknown): value is Iterable<unknown> | AsyncIterable<unknown> {
  903. return isObject(value)
  904. && (typeof Reflect.get(value, Symbol.iterator) === 'function'
  905. || typeof Reflect.get(value, Symbol.asyncIterator) === 'function')
  906. }
  907. async function *cancellableStream(
  908. source: Iterable<unknown> | AsyncIterable<unknown>,
  909. endpoint: string,
  910. signal: AbortSignal,
  911. ): AsyncGenerator {
  912. const asyncFactory = Reflect.get(source, Symbol.asyncIterator) as unknown
  913. const syncFactory = Reflect.get(source, Symbol.iterator) as unknown
  914. const iterator = typeof asyncFactory === 'function'
  915. ? Reflect.apply(asyncFactory, source, []) as AsyncIterator<unknown>
  916. : Reflect.apply(syncFactory as (...args: never[]) => Iterator<unknown>, source, [])
  917. let rejectAbort: ((error: unknown) => void) | undefined
  918. const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
  919. const onAbort = (): void => {
  920. rejectAbort?.(remoteCancelled(endpoint, signal.reason))
  921. }
  922. signal.addEventListener('abort', onAbort, { once: true })
  923. try {
  924. if (signal.aborted) throw remoteCancelled(endpoint, signal.reason)
  925. while (true) {
  926. const next = await Promise.race([Promise.resolve(iterator.next()), aborted])
  927. if (next.done === true) return
  928. yield next.value
  929. }
  930. } finally {
  931. signal.removeEventListener('abort', onAbort)
  932. await iterator.return?.()
  933. }
  934. }
  935. /** Carrier-signal cancellation as the shared failure vocabulary expresses it. */
  936. function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> {
  937. return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause })
  938. }
  939. function rpcFailure(error: unknown): ConnectionRpcResult {
  940. const remote = remoteErrorOf(error)
  941. if (remote !== undefined) {
  942. return { ok: false, error: { code: remote.code, message: remote.message, details: remote.details } }
  943. }
  944. return {
  945. ok: false,
  946. error: {
  947. code: 'gateway/internal',
  948. message: error instanceof Error ? error.message : String(error),
  949. details: {},
  950. },
  951. }
  952. }
  953. function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure {
  954. return (rpcFailure(error) as Extract<ConnectionRpcResult, { readonly ok: false }>).error
  955. }
  956. function endpointOf(namespace: string, method: string): string {
  957. return `${namespace}/${method}`
  958. }
  959. function validateBinding(
  960. receiver: object,
  961. serviceKey: string,
  962. namespace: string,
  963. endpoint: string,
  964. ): ResolvedBinding {
  965. const original = originalOf(receiver)
  966. const value = Reflect.get(original, 'typertRemote') as unknown
  967. if (value === undefined) {
  968. throw new TypertGatewayError(
  969. 'gateway/binding-invalid',
  970. endpoint,
  971. `Service ${JSON.stringify(serviceKey)} has no visible typertRemote binding`,
  972. )
  973. }
  974. return {
  975. binding: readBinding(value, original, serviceKey, endpoint, namespace),
  976. original,
  977. }
  978. }
  979. function readBinding(
  980. value: unknown,
  981. original: object,
  982. serviceKey: string,
  983. endpoint: string,
  984. namespace?: string,
  985. ): TypertGatewayBinding {
  986. if (!isObject(value)
  987. || Reflect.get(value, 'service') !== original
  988. || Reflect.get(value, 'serviceKey') !== serviceKey
  989. || typeof Reflect.get(value, 'namespace') !== 'string'
  990. || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
  991. throw new TypertGatewayError(
  992. 'gateway/binding-invalid',
  993. endpoint,
  994. `Service ${JSON.stringify(serviceKey)} has an inconsistent typertRemote binding`,
  995. )
  996. }
  997. return value as unknown as TypertGatewayBinding
  998. }
  999. function originalOf(receiver: object): object {
  1000. const original = Reflect.get(receiver, symbols.original) as unknown
  1001. return isObject(original) ? original : receiver
  1002. }
  1003. function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] {
  1004. let prototype: object | null = Object.getPrototypeOf(service) as object | null
  1005. let implementation: ((this: object, ...args: never[]) => unknown) | undefined
  1006. while (prototype !== null) {
  1007. const descriptor = Object.getOwnPropertyDescriptor(prototype, method)
  1008. if (descriptor !== undefined) {
  1009. if ('value' in descriptor && typeof descriptor.value === 'function') {
  1010. implementation = descriptor.value as (this: object, ...args: never[]) => unknown
  1011. }
  1012. break
  1013. }
  1014. prototype = Object.getPrototypeOf(prototype) as object | null
  1015. }
  1016. if (implementation === undefined) {
  1017. throw new TypertGatewayError(
  1018. 'gateway/method-unavailable',
  1019. endpoint,
  1020. `Remote marker has no prototype method ${JSON.stringify(method)}`,
  1021. )
  1022. }
  1023. const source = Function.prototype.toString.call(implementation)
  1024. const open = source.indexOf('(')
  1025. const close = source.indexOf(')', open + 1)
  1026. /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
  1027. if (open < 0 || close < 0) return invalidSignature(endpoint, method)
  1028. const body = source.slice(open + 1, close).trim()
  1029. if (body.length === 0) return []
  1030. const parts = body.split(',').map(part => part.trim())
  1031. const names = new Set<string>()
  1032. for (const part of parts) {
  1033. if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
  1034. names.add(part)
  1035. }
  1036. return [...names]
  1037. }
  1038. function invalidSignature(endpoint: string, method: string): never {
  1039. throw new TypertGatewayError(
  1040. 'gateway/signature-invalid',
  1041. endpoint,
  1042. `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
  1043. )
  1044. }
  1045. function assertExactArguments(
  1046. args: Readonly<Record<string, unknown>>,
  1047. descriptor: InvocationDescriptor,
  1048. endpoint: string,
  1049. ): void {
  1050. if (!isPlainObject(args)) {
  1051. throw new TypertGatewayError('gateway/arguments-invalid', endpoint, 'args must be a plain object')
  1052. }
  1053. const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
  1054. if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
  1055. const actual = Reflect.ownKeys(args)
  1056. const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
  1057. // A JSON field may be omitted when the strict descriptor declares absence,
  1058. // and always under SRC: a weak descriptor reads parameter names from the
  1059. // JavaScript signature and cannot see which are optional, so LIB is where an
  1060. // omitted required argument is caught. Lookup ids are never omissible.
  1061. const acceptsMissing = new Set(descriptor.parameters
  1062. .filter(parameter => parameter.source === 'json'
  1063. && (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json'))
  1064. .map(parameter => parameter.wire))
  1065. const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key))
  1066. if (extra.length === 0 && missing.length === 0) return
  1067. const clauses: string[] = []
  1068. if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
  1069. if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
  1070. throw new TypertGatewayError('gateway/arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
  1071. }
  1072. function decode(
  1073. codec: TypertCodec,
  1074. value: unknown,
  1075. endpoint: string,
  1076. field: string,
  1077. ): unknown {
  1078. try {
  1079. if (codec.mode === 'strict') {
  1080. value = codec.schema.parse(value)
  1081. /* v8 ignore next -- generated optional-input codecs are the only strict codecs that return undefined. */
  1082. if (value === undefined) return value
  1083. }
  1084. assertJsonValue(value, new Set())
  1085. return value
  1086. } catch (cause) {
  1087. throw new TypertGatewayError(
  1088. 'gateway/input-invalid',
  1089. endpoint,
  1090. `wire field ${JSON.stringify(field)} failed boundary validation`,
  1091. { cause, field },
  1092. )
  1093. }
  1094. }
  1095. function assertJsonValue(value: unknown, ancestors: Set<object>): void {
  1096. if (value === null || typeof value === 'string' || typeof value === 'boolean') return
  1097. if (typeof value === 'number') {
  1098. if (Number.isFinite(value)) return
  1099. throw new TypeError('non-finite number is not JSON-safe')
  1100. }
  1101. if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)
  1102. if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')
  1103. ancestors.add(value)
  1104. try {
  1105. if (Array.isArray(value)) {
  1106. if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
  1107. throw new TypeError('sparse or decorated array is not JSON-safe')
  1108. }
  1109. for (let index = 0; index < value.length; index += 1) {
  1110. if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe')
  1111. assertJsonValue(value[index], ancestors)
  1112. }
  1113. return
  1114. }
  1115. if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
  1116. if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
  1117. for (const key of Reflect.ownKeys(value)) {
  1118. const descriptor = Object.getOwnPropertyDescriptor(value, key)
  1119. /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
  1120. if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
  1121. throw new TypeError('non-data property is not JSON-safe')
  1122. }
  1123. assertJsonValue(descriptor.value, ancestors)
  1124. }
  1125. } finally {
  1126. ancestors.delete(value)
  1127. }
  1128. }
  1129. function isPlainObject(value: object): value is Record<string, unknown> {
  1130. if (Array.isArray(value)) return false
  1131. const prototype = Object.getPrototypeOf(value) as object | null
  1132. return prototype === null || prototype === Object.prototype
  1133. }
  1134. function isObject(value: unknown): value is object {
  1135. return (typeof value === 'object' && value !== null) || typeof value === 'function'
  1136. }
  1137. export default TypertGatewayService