index.ts 43 KB

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