index.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. /**
  2. * Live TypeRT Remote dispatch over Cordis Services and registered providers.
  3. * Transport, request correlation, and response envelopes belong to Connection.
  4. * @module @deepseek-ai/dsh-api-gateway
  5. */
  6. import { Context, Service, symbols } from '@deepseek-ai/cordis'
  7. import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
  8. import {
  9. remoteMethods,
  10. TypeRTLookupFailure,
  11. type InvocationDescriptor,
  12. type InvocationParameterDescriptor,
  13. type TypeRTCodec,
  14. type TypeRTGatewayBinding,
  15. } from '@deepseek-ai/dsh-type-meta'
  16. import type {
  17. InvokeRemoteRequest,
  18. TypertGateway,
  19. TypertGatewayErrorCode,
  20. } from './types.ts'
  21. export type {
  22. InvokeRemoteRequest,
  23. TypertGateway,
  24. TypertGatewayErrorCode,
  25. } from './types.ts'
  26. interface GatewayErrorOptions {
  27. readonly cause?: unknown
  28. readonly field?: string
  29. }
  30. interface ResolvedBinding {
  31. readonly binding: TypeRTGatewayBinding
  32. readonly original: object
  33. }
  34. type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
  35. type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
  36. const NEVER_ABORTED_SIGNAL = new AbortController().signal
  37. /** Dispatch failure produced outside the invoked business method. */
  38. export class TypertGatewayError extends Error {
  39. /** Machine-readable failure category. */
  40. readonly code: TypertGatewayErrorCode
  41. /** Canonical `<namespace>/<method>` endpoint. */
  42. readonly endpoint: string
  43. /** Affected wire field when the failure is field-specific. */
  44. readonly field: string | undefined
  45. /**
  46. * Construct a Gateway failure without embedding boundary values in its message.
  47. * @param code - stable failure category.
  48. * @param endpoint - canonical Remote endpoint.
  49. * @param message - correction-oriented diagnostic without sensitive values.
  50. * @param options - optional field and contained cause.
  51. */
  52. constructor(
  53. code: TypertGatewayErrorCode,
  54. endpoint: string,
  55. message: string,
  56. options: GatewayErrorOptions = {},
  57. ) {
  58. super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })
  59. this.name = 'TypertGatewayError'
  60. this.code = code
  61. this.endpoint = endpoint
  62. this.field = options.field
  63. }
  64. }
  65. /**
  66. * Resolve strict generated definitions or conservative SRC markers against
  67. * current Cordis Services and TypeRT providers.
  68. * @typert service typertGateway
  69. */
  70. export class TypertGatewayService extends Service implements TypertGateway {
  71. static inject = ['typert']
  72. private srcClaims: ReadonlySet<string> | undefined
  73. /**
  74. * Register the Gateway against the active TypeRT registry.
  75. * @param ctx - owning Host Context with TypeRT registry access.
  76. */
  77. constructor(ctx: Context) {
  78. super(ctx, 'typertGateway')
  79. ctx.on('internal/service', () => {
  80. this.srcClaims = undefined
  81. })
  82. ctx.inject(['connection'], (connectionCtx) => {
  83. connectionCtx.connection.rpc.intercept(
  84. '/api',
  85. endpoint => this.claimsEndpoint(endpoint),
  86. (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
  87. { authority: 'trusted-host' },
  88. )
  89. })
  90. }
  91. private claimsEndpoint(endpoint: string): boolean {
  92. const segments = endpoint.split('/')
  93. if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
  94. if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
  95. this.srcClaims ??= this.collectSrcClaims()
  96. return this.srcClaims.has(endpoint)
  97. }
  98. private collectSrcClaims(): ReadonlySet<string> {
  99. const claims = new Set<string>()
  100. for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
  101. if (definition.type !== 'service') continue
  102. const receiver = this.ctx.get(serviceKey) as unknown
  103. if (!isObject(receiver)) continue
  104. const original = originalOf(receiver)
  105. const binding = Reflect.get(original, 'typertGateway') as unknown
  106. if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue
  107. const namespace = Reflect.get(binding, 'namespace') as string
  108. for (const candidate of remoteMethods(original)) {
  109. claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method))
  110. }
  111. }
  112. return claims
  113. }
  114. /**
  115. * Invoke one live Remote method through strict generated reflection or SRC markers.
  116. * @param request - decoded endpoint and exact named wire arguments.
  117. * @returns the validated business result.
  118. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
  119. */
  120. async invoke(request: InvokeRemoteRequest): Promise<unknown> {
  121. const endpoint = endpointOf(request.namespace, request.method)
  122. const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
  123. assertExactArguments(request.args, descriptor, endpoint)
  124. const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
  125. const receiver = receiverContext.get(descriptor.service) as unknown
  126. if (!isObject(receiver)) {
  127. throw new TypertGatewayError(
  128. 'service-unavailable',
  129. endpoint,
  130. `active Service ${JSON.stringify(descriptor.service)} is unavailable`,
  131. )
  132. }
  133. validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
  134. const args = await Promise.all(descriptor.parameters.map(parameter =>
  135. this.resolveParameter(parameter, request.args, endpoint)))
  136. if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
  137. const implementation = descriptor.implementation ?? descriptor.method
  138. const method = Reflect.get(receiver, implementation) as unknown
  139. if (typeof method !== 'function') {
  140. throw new TypertGatewayError(
  141. 'method-unavailable',
  142. endpoint,
  143. `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
  144. )
  145. }
  146. const result = await Reflect.apply(method, receiver, args) as unknown
  147. return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
  148. }
  149. private async dispatchRpc(
  150. endpoint: string,
  151. payload: unknown,
  152. signal: AbortSignal,
  153. ): Promise<ConnectionRpcResult> {
  154. return this.invokeRpc(endpoint, payload, signal)
  155. }
  156. private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
  157. try {
  158. const segments = endpoint.split('/')
  159. if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
  160. throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
  161. }
  162. const [namespace, method] = segments as [string, string]
  163. if (!isObject(payload)
  164. || !isPlainObject(payload)
  165. || Reflect.ownKeys(payload).length !== 1
  166. || !Object.hasOwn(payload, 'args')
  167. || !isObject(payload.args)
  168. || !isPlainObject(payload.args)) {
  169. throw new Error('Remote payload must contain exactly one plain-object args field')
  170. }
  171. const value = await this.invoke({
  172. namespace,
  173. method,
  174. args: payload.args,
  175. signal,
  176. })
  177. return { ok: true, value }
  178. } catch (error) {
  179. return rpcFailure(error)
  180. }
  181. }
  182. private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
  183. const strict = this.ctx.typert.local.get(endpoint)
  184. if (strict !== undefined) return strict
  185. if (this.ctx.typert.local.hasSeen(endpoint)) {
  186. throw new TypertGatewayError(
  187. 'definition-unavailable',
  188. endpoint,
  189. 'its strict definition was withdrawn and SRC fallback is forbidden',
  190. )
  191. }
  192. return this.resolveSrcDescriptor(namespace, method, endpoint)
  193. }
  194. private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
  195. const candidates: InvocationDescriptor[] = []
  196. for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
  197. if (definition.type !== 'service') continue
  198. const receiver = this.ctx.get(serviceKey) as unknown
  199. if (!isObject(receiver)) continue
  200. const original = originalOf(receiver)
  201. const value = Reflect.get(original, 'typertGateway') as unknown
  202. if (value === undefined) continue
  203. const binding = readBinding(value, original, serviceKey, endpoint)
  204. if (binding.namespace !== namespace) continue
  205. const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method)
  206. if (marker === undefined) continue
  207. candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
  208. }
  209. if (candidates.length === 0) {
  210. throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
  211. }
  212. if (candidates.length > 1) {
  213. throw new TypertGatewayError(
  214. 'ambiguous-endpoint',
  215. endpoint,
  216. `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
  217. )
  218. }
  219. return candidates[0] as InvocationDescriptor
  220. }
  221. private srcDescriptor(
  222. binding: TypeRTGatewayBinding,
  223. marker: ReturnType<typeof remoteMethods>[number],
  224. method: string,
  225. endpoint: string,
  226. ): InvocationDescriptor {
  227. const names = methodParameterNames(binding.service, marker.method, endpoint)
  228. const signalIndex = names.indexOf('signal')
  229. if (signalIndex >= 0 && signalIndex !== names.length - 1) {
  230. throw new TypertGatewayError(
  231. 'signature-invalid',
  232. endpoint,
  233. 'SRC cancellation parameter signal must be the final parameter',
  234. { field: 'signal' },
  235. )
  236. }
  237. const cancellation = signalIndex >= 0
  238. ? { parameter: 'signal' as const }
  239. : undefined
  240. const businessNames = cancellation === undefined ? names : names.slice(0, -1)
  241. const parameters: InvocationParameterDescriptor[] = []
  242. const wires = new Set<string>()
  243. for (const name of businessNames) {
  244. const matches = this.ctx.typert.lookups.definitions()
  245. .filter(definition => definition.parameter === name)
  246. if (matches.length > 1) {
  247. throw new TypertGatewayError(
  248. 'signature-invalid',
  249. endpoint,
  250. `parameter ${JSON.stringify(name)} matches multiple lookup providers`,
  251. { field: name },
  252. )
  253. }
  254. const match = matches[0]
  255. const parameter: InvocationParameterDescriptor = match === undefined
  256. ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
  257. : {
  258. name,
  259. wire: match.wire,
  260. source: 'lookup',
  261. lookup: match.key,
  262. codec: { mode: 'src-json' },
  263. }
  264. if (wires.has(parameter.wire)) {
  265. throw new TypertGatewayError(
  266. 'signature-invalid',
  267. endpoint,
  268. `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
  269. { field: parameter.wire },
  270. )
  271. }
  272. wires.add(parameter.wire)
  273. parameters.push(parameter)
  274. }
  275. let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' }
  276. if (marker.invocation.kind === 'context') {
  277. const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
  278. if (provider === undefined) {
  279. throw new TypertGatewayError(
  280. 'context-unavailable',
  281. endpoint,
  282. `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
  283. )
  284. }
  285. if (wires.has(provider.wire)) {
  286. throw new TypertGatewayError(
  287. 'signature-invalid',
  288. endpoint,
  289. `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
  290. { field: provider.wire },
  291. )
  292. }
  293. receiver = {
  294. kind: 'context',
  295. context: marker.invocation.context,
  296. wire: provider.wire,
  297. codec: { mode: 'src-json' },
  298. }
  299. }
  300. return {
  301. id: `src:${binding.serviceKey}#${endpoint}`,
  302. service: binding.serviceKey,
  303. namespace: binding.namespace,
  304. method,
  305. ...(marker.method === method ? {} : { implementation: marker.method }),
  306. invocation: receiver,
  307. parameters,
  308. ...(cancellation === undefined ? {} : { cancellation }),
  309. result: { mode: 'src-json' },
  310. }
  311. }
  312. private async resolveReceiverContext(
  313. descriptor: InvocationDescriptor,
  314. args: Readonly<Record<string, unknown>>,
  315. endpoint: string,
  316. ): Promise<Context> {
  317. if (descriptor.invocation.kind === 'direct') return this.ctx
  318. const invocation = descriptor.invocation
  319. const provider = this.ctx.typert.contexts.getHost(invocation.context)
  320. if (provider === undefined) {
  321. throw new TypertGatewayError(
  322. 'context-unavailable',
  323. endpoint,
  324. `Context provider ${JSON.stringify(invocation.context)} is unavailable`,
  325. )
  326. }
  327. if (provider.wire !== invocation.wire
  328. || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
  329. throw new TypertGatewayError(
  330. 'provider-mismatch',
  331. endpoint,
  332. `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
  333. { field: invocation.wire },
  334. )
  335. }
  336. const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
  337. let context: Context | undefined
  338. try {
  339. context = await provider.resolve(identity)
  340. } catch (cause) {
  341. if (cause instanceof TypeRTLookupFailure) throw cause
  342. throw new TypertGatewayError(
  343. 'context-failed',
  344. endpoint,
  345. `Context provider ${JSON.stringify(invocation.context)} failed`,
  346. { cause, field: invocation.wire },
  347. )
  348. }
  349. if (context === undefined) {
  350. throw new TypertGatewayError(
  351. 'context-not-found',
  352. endpoint,
  353. `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
  354. { field: invocation.wire },
  355. )
  356. }
  357. return context
  358. }
  359. private async resolveParameter(
  360. parameter: InvocationParameterDescriptor,
  361. args: Readonly<Record<string, unknown>>,
  362. endpoint: string,
  363. ): Promise<unknown> {
  364. const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
  365. if (parameter.source === 'json') return value
  366. const key = parameter.lookup
  367. /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
  368. if (key === undefined) {
  369. throw new TypertGatewayError(
  370. 'lookup-unavailable',
  371. endpoint,
  372. `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
  373. { field: parameter.wire },
  374. )
  375. }
  376. const provider = this.ctx.typert.lookups.get(key)
  377. if (provider === undefined) {
  378. throw new TypertGatewayError(
  379. 'lookup-unavailable',
  380. endpoint,
  381. `lookup provider ${JSON.stringify(key)} is unavailable`,
  382. { field: parameter.wire },
  383. )
  384. }
  385. if (provider.wire !== parameter.wire
  386. || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
  387. throw new TypertGatewayError(
  388. 'provider-mismatch',
  389. endpoint,
  390. `lookup provider ${JSON.stringify(key)} does not match its strict definition`,
  391. { field: parameter.wire },
  392. )
  393. }
  394. let resolved: unknown
  395. try {
  396. resolved = await provider.resolve(value)
  397. } catch (cause) {
  398. if (cause instanceof TypeRTLookupFailure) throw cause
  399. throw new TypertGatewayError(
  400. 'lookup-failed',
  401. endpoint,
  402. `lookup provider ${JSON.stringify(key)} failed`,
  403. { cause, field: parameter.wire },
  404. )
  405. }
  406. if (resolved === undefined) {
  407. throw new TypertGatewayError(
  408. 'lookup-not-found',
  409. endpoint,
  410. `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
  411. { field: parameter.wire },
  412. )
  413. }
  414. return resolved
  415. }
  416. }
  417. function rpcFailure(error: unknown): ConnectionRpcResult {
  418. if (error instanceof TypeRTLookupFailure) {
  419. return { ok: false, error: error.failure as ConnectionRpcError }
  420. }
  421. return {
  422. ok: false,
  423. error: {
  424. code: 'internal',
  425. message: error instanceof Error ? error.message : String(error),
  426. details: {},
  427. },
  428. }
  429. }
  430. function endpointOf(namespace: string, method: string): string {
  431. return `${namespace}/${method}`
  432. }
  433. function validateBinding(
  434. receiver: object,
  435. serviceKey: string,
  436. namespace: string,
  437. endpoint: string,
  438. ): ResolvedBinding {
  439. const original = originalOf(receiver)
  440. const value = Reflect.get(original, 'typertGateway') as unknown
  441. if (value === undefined) {
  442. throw new TypertGatewayError(
  443. 'binding-invalid',
  444. endpoint,
  445. `Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`,
  446. )
  447. }
  448. return {
  449. binding: readBinding(value, original, serviceKey, endpoint, namespace),
  450. original,
  451. }
  452. }
  453. function readBinding(
  454. value: unknown,
  455. original: object,
  456. serviceKey: string,
  457. endpoint: string,
  458. namespace?: string,
  459. ): TypeRTGatewayBinding {
  460. if (!isObject(value)
  461. || Reflect.get(value, 'service') !== original
  462. || Reflect.get(value, 'serviceKey') !== serviceKey
  463. || typeof Reflect.get(value, 'namespace') !== 'string'
  464. || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
  465. throw new TypertGatewayError(
  466. 'binding-invalid',
  467. endpoint,
  468. `Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`,
  469. )
  470. }
  471. return value as unknown as TypeRTGatewayBinding
  472. }
  473. function originalOf(receiver: object): object {
  474. const original = Reflect.get(receiver, symbols.original) as unknown
  475. return isObject(original) ? original : receiver
  476. }
  477. function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] {
  478. let prototype: object | null = Object.getPrototypeOf(service) as object | null
  479. let implementation: ((this: object, ...args: never[]) => unknown) | undefined
  480. while (prototype !== null) {
  481. const descriptor = Object.getOwnPropertyDescriptor(prototype, method)
  482. if (descriptor !== undefined) {
  483. if ('value' in descriptor && typeof descriptor.value === 'function') {
  484. implementation = descriptor.value as (this: object, ...args: never[]) => unknown
  485. }
  486. break
  487. }
  488. prototype = Object.getPrototypeOf(prototype) as object | null
  489. }
  490. if (implementation === undefined) {
  491. throw new TypertGatewayError(
  492. 'method-unavailable',
  493. endpoint,
  494. `Remote marker has no prototype method ${JSON.stringify(method)}`,
  495. )
  496. }
  497. const source = Function.prototype.toString.call(implementation)
  498. const open = source.indexOf('(')
  499. const close = source.indexOf(')', open + 1)
  500. /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
  501. if (open < 0 || close < 0) return invalidSignature(endpoint, method)
  502. const body = source.slice(open + 1, close).trim()
  503. if (body.length === 0) return []
  504. const parts = body.split(',').map(part => part.trim())
  505. const names = new Set<string>()
  506. for (const part of parts) {
  507. if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
  508. names.add(part)
  509. }
  510. return [...names]
  511. }
  512. function invalidSignature(endpoint: string, method: string): never {
  513. throw new TypertGatewayError(
  514. 'signature-invalid',
  515. endpoint,
  516. `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
  517. )
  518. }
  519. function assertExactArguments(
  520. args: Readonly<Record<string, unknown>>,
  521. descriptor: InvocationDescriptor,
  522. endpoint: string,
  523. ): void {
  524. if (!isPlainObject(args)) {
  525. throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object')
  526. }
  527. const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
  528. if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
  529. const actual = Reflect.ownKeys(args)
  530. const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
  531. const missing = [...expected].filter(key => !Object.hasOwn(args, key))
  532. if (extra.length === 0 && missing.length === 0) return
  533. const clauses: string[] = []
  534. if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
  535. if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
  536. throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
  537. }
  538. function decode(
  539. codec: TypeRTCodec,
  540. value: unknown,
  541. code: 'input-invalid' | 'result-invalid',
  542. endpoint: string,
  543. field: string,
  544. ): unknown {
  545. try {
  546. if (codec.mode === 'strict') value = codec.schema.parse(value)
  547. assertJsonValue(value, new Set())
  548. return value
  549. } catch (cause) {
  550. throw new TypertGatewayError(
  551. code,
  552. endpoint,
  553. code === 'input-invalid'
  554. ? `wire field ${JSON.stringify(field)} failed boundary validation`
  555. : 'business result failed boundary validation',
  556. { cause, field },
  557. )
  558. }
  559. }
  560. function assertJsonValue(value: unknown, ancestors: Set<object>): void {
  561. if (value === null || typeof value === 'string' || typeof value === 'boolean') return
  562. if (typeof value === 'number') {
  563. if (Number.isFinite(value)) return
  564. throw new TypeError('non-finite number is not JSON-safe')
  565. }
  566. if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)
  567. if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')
  568. ancestors.add(value)
  569. try {
  570. if (Array.isArray(value)) {
  571. if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
  572. throw new TypeError('sparse or decorated array is not JSON-safe')
  573. }
  574. for (let index = 0; index < value.length; index += 1) {
  575. if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe')
  576. assertJsonValue(value[index], ancestors)
  577. }
  578. return
  579. }
  580. if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
  581. if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
  582. for (const key of Reflect.ownKeys(value)) {
  583. const descriptor = Object.getOwnPropertyDescriptor(value, key)
  584. /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
  585. if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
  586. throw new TypeError('non-data property is not JSON-safe')
  587. }
  588. assertJsonValue(descriptor.value, ancestors)
  589. }
  590. } finally {
  591. ancestors.delete(value)
  592. }
  593. }
  594. function isPlainObject(value: object): value is Record<string, unknown> {
  595. if (Array.isArray(value)) return false
  596. const prototype = Object.getPrototypeOf(value) as object | null
  597. return prototype === null || prototype === Object.prototype
  598. }
  599. function isObject(value: unknown): value is object {
  600. return (typeof value === 'object' && value !== null) || typeof value === 'function'
  601. }
  602. export default TypertGatewayService