/** * Live Typert Remote dispatch over Cordis Services and registered providers. * Unary transport and response envelopes belong to Connection; live Remote * streams use the Gateway-owned WebSocket mux. * @module @deepseek-ai/dsh-api-gateway */ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcAttachment, ConnectionRpcHandler, } from '@deepseek-ai/dsh-client-connection' import type { RemoteAccessDecision } from '@deepseek-ai/dsh-api-remote-access' import { callResources, eventResources } from './access.ts' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type {} from '@deepseek-ai/dsh-cmdline' import z from '@deepseek-ai/schemastery' export type { TypertGatewayFaultDetails } from './remote-error-codes.ts' import { RemoteError, isRemoteJsonValue, remoteErrorOf, remoteMethods, type InvocationDescriptor, type InvocationParameterDescriptor, type PeerScope, type Principal, type RemoteAccessResource, type RemoteInvocation, type RemoteMethodMarker, type TypertCodec, type TypertGatewayBinding, } from '@deepseek-ai/dsh-typert-protocol' import type { InvokeRemoteRequest, PendingRemoteEventInfo, TypertGateway, TypertGatewayErrorCode, TypertGatewayWireStream, TypertRemoteEventDispatch, TypertRemoteEventFrame, TypertRemoteEventInvocation, TypertRemoteEventOutcome, TypertRemoteEventSource, } from './types.ts' import { RemoteStreamMuxServer, rejectRemoteStreamUpgrade, } from './stream-server.ts' import { REMOTE_EVENT_STREAM_ENDPOINT, REMOTE_EVENT_STREAM_READY, REMOTE_EVENT_RESULT_ENDPOINT, REMOTE_STREAM_MUX_PATH, isRemoteEventAgentId, parseRemoteEventResult, projectRemoteEventRequest, restoreRemoteEventRejection, type RemoteEventCancellationFrame, type RemoteEventClientId, type RemoteEventEmitFrame, type RemoteEventHostInfo, type RemoteEventId, type RemoteEventInvocationFrame, type RemoteEventReadyFrame, type RemoteStreamFailure, } from './stream-protocol.ts' export type { InvokeRemoteRequest, PendingRemoteEventInfo, TypertGateway, TypertGatewayErrorCode, TypertGatewayWireStream, TypertRemoteEventContext, TypertRemoteEventDispatch, TypertRemoteEventFrame, TypertRemoteEventInvocation, TypertRemoteEventOutcome, TypertRemoteEventSource, } from './types.ts' export type { RemoteEventHostInfo } from './stream-protocol.ts' interface GatewayErrorOptions { readonly cause?: unknown readonly field?: string } interface ResolvedBinding { readonly binding: TypertGatewayBinding readonly original: object } interface PreparedInvocation { readonly endpoint: string readonly descriptor: InvocationDescriptor /** Service view bound to a Context carrying `invocation`, so the method reads it as `this.ctx.invocation`. */ readonly receiver: object readonly args: readonly unknown[] readonly method: (...args: never[]) => unknown readonly invocation: GatewayInvocation } /** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */ interface UplinkSource { readonly window: RemoteInvocation['uplinkWindow'] /** Carrier items; an immediately ended iterable when the carrier has none. */ readonly source: AsyncIterable /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */ readonly codec: TypertCodec readonly endpoint: string /** Fail the logical stream with a Remote failure as the reason. */ readonly abort: (reason: unknown) => void } interface RegisteredRemoteEventSource { readonly lifetime: AbortController readonly done: Promise readonly host: RemoteEventHostInfo } interface RemoteEventClient { readonly peer: PeerScope readonly id: RemoteEventClientId readonly queue: RemoteEventQueue readonly signal: AbortSignal readonly deliveries: Map /** The stream's Peer principal: only the owner answers scoped waterfalls and receives every broadcast. */ readonly principal: Principal /** Only owner streams receive scoped waterfalls they could answer; other Peers receive admitted broadcasts only. */ readonly answers: boolean } interface PendingRemoteEvent { readonly id: RemoteEventId readonly source: TypertRemoteEventInvocation readonly frame: RemoteEventInvocationFrame readonly deliveries: Set releaseContext: () => void releaseSignal: () => void } type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000 const DEFAULT_STREAM_INBOX_BYTES = 262_144 const EMPTY_ASYNC_ITERABLE: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }), } const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } const SRC_JSON_CODEC: TypertCodec = { mode: 'src-json' } /** Gateway transport configuration. */ export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ readonly streamInboxBytes?: number /** Per-message compression for the Remote stream WebSocket; `'none'` disables the extension. @default 'deflate' */ readonly websocketCompression?: 'none' | 'deflate' } interface ResolvedConfig extends Config { readonly websocketHeartbeatIntervalMs: number readonly streamInboxBytes: number readonly websocketCompression: 'none' | 'deflate' } /** * Dispatch failure produced outside the invoked business method. Rides the * shared Remote failure vocabulary, so its code crosses the wire instead of * folding to `internal`. */ export class TypertGatewayError extends RemoteError { /** Canonical `/` endpoint. */ readonly endpoint: string /** Affected wire field when the failure is field-specific. */ readonly field: string | undefined /** * Construct a Gateway failure without embedding boundary values in its message. * @param code - stable failure category. * @param endpoint - canonical Remote endpoint. * @param message - correction-oriented diagnostic without sensitive values. * @param options - optional field and contained cause. */ constructor( code: TypertGatewayErrorCode, endpoint: string, message: string, options: GatewayErrorOptions = {}, ) { super( code, `typert gateway: ${endpoint}: ${message}`, { endpoint, ...options.field === undefined ? {} : { field: options.field } }, options.cause === undefined ? undefined : { cause: options.cause }, ) this.name = 'TypertGatewayError' this.endpoint = endpoint this.field = options.field } } /** * Resolve strict generated definitions or conservative SRC markers against * current Cordis Services and Typert providers. * @typert service typertGateway */ export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert', 'remoteAccess'] static Config: z = z.object({ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES), websocketCompression: z.union([z.const('none'), z.const('deflate')]).default('deflate'), }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ readonly wireStream: TypertGatewayWireStream = { open: (endpoint, payload, uplink, peer, signal) => this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()), failure: error => rpcError(error), } private srcClaims: ReadonlySet | undefined private remoteEvents: RegisteredRemoteEventSource | undefined private readonly remoteEventClients = new Map() private readonly pendingRemoteEvents = new Map() /** * Register the Gateway against the active Typert registry. * WebSocket admission waits for launcher-owned application readiness when supplied; * direct invocation and in-process streams remain available independently. * @param ctx - owning Host Context with Typert registry access. * @param config - validated Gateway transport configuration. */ constructor(ctx: Context, config: Config) { super(ctx, 'typertGateway') const resolved = config as ResolvedConfig ctx.on('internal/service', () => { this.srcClaims = undefined }) ctx.inject(['connection'], (connectionCtx) => { connectionCtx.connection.rpc.intercept( '/api', endpoint => this.claimsEndpoint(endpoint), (endpoint, payload, signal, peer) => this.dispatchRpc(endpoint, payload, signal, peer), ) }) ctx.inject(['connection', 'webServer'], (webCtx) => { const listen = (): void => { const mux = new RemoteStreamMuxServer( (endpoint, payload, uplink, peer, control, window) => this.openWireStream(endpoint, payload, uplink, peer, control.signal, control, window), this.wireStream.failure, { heartbeatIntervalMs: resolved.websocketHeartbeatIntervalMs, streamInboxBytes: resolved.streamInboxBytes, compression: resolved.websocketCompression === 'deflate', }, ) const releases = new Set>() webCtx.effect(function* () { yield async () => { await mux.close() await Promise.all(releases) } const route: WebUpgradeRoute = { path: REMOTE_STREAM_MUX_PATH, handler: (req, socket, head) => { const admission = webCtx.connection.admitConnection(req) if ('rejection' in admission) { rejectRemoteStreamUpgrade(socket, admission.rejection) return } const release = (): void => { // release removes the API Peer synchronously, before awaiting // scope cleanup or any logical-stream iterator's return. const done = admission.release().catch((error: unknown) => { console.error('[api-gateway] Peer release failed:', error) }) releases.add(done) void done.then(() => { releases.delete(done) }) } const closed = (): void => { socket.removeListener('error', release) release() } socket.on('error', release) socket.once('close', closed) if (socket.destroyed) { closed() return } try { mux.handleUpgrade(req, socket, head, admission.peer) } catch (error) { release() socket.destroy() console.error('[api-gateway] Remote stream upgrade failed:', error) } }, } yield webCtx.webServer.registerUpgrade(route) }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`) } // Existing pages reconnect before the new Host prints its URL. No stream // may enter until the launcher has activated and audited its controllers. const ready = webCtx.get('appReady') if (ready === undefined) listen() else webCtx.effect(() => { let closed = false const cancel = ready.onReady(() => { if (!closed) listen() }) return () => { closed = true cancel() } }, 'api-gateway: application readiness') }) } /** * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean { for (const client of this.remoteEventClients.values()) { if (client.answers && !client.signal.aborted) return true } return false } /** * Inspect unsettled forwarded events without changing their lifetime. * @returns detached event names and Agent identities. */ getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] { return [...this.pendingRemoteEvents.values()].map(({ frame }) => ({ event: frame.event, agentId: frame.agentId })) } /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise { if (this.remoteEvents !== undefined) { throw new Error('typert gateway: forwarded Remote event source is already registered') } const lifetime = new AbortController() const stream = source(lifetime.signal) const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => { if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return this.closeRemoteEvents(error) this.remoteEvents = undefined lifetime.abort(error) }) const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } } this.remoteEvents = registration return async () => { if (this.remoteEvents === registration) { this.remoteEvents = undefined const error = new Error('typert gateway: forwarded Remote event source was removed') registration.lifetime.abort(error) this.closeRemoteEvents(error) } await registration.done } } private claimsEndpoint(endpoint: string): boolean { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true this.srcClaims ??= this.collectSrcClaims() return this.srcClaims.has(endpoint) } /** * The `resources` readers of a method, read from the root Service's class * metadata so a Context method's receiver is not resolved before access is * decided; `undefined` when the Service is absent or the method declares none. */ private resourceReaders(descriptor: InvocationDescriptor): RemoteMethodMarker['resources'] { const receiver: unknown = this.ctx.get(descriptor.service) if (!isObject(receiver)) return undefined const member = descriptor.implementation ?? descriptor.method return remoteMethods(originalOf(receiver)).find(marker => marker.method === member)?.resources } private collectSrcClaims(): ReadonlySet { const claims = new Set() for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { if (definition.type !== 'service') continue const receiver: unknown = this.ctx.get(serviceKey) if (!isObject(receiver)) continue const original = originalOf(receiver) const binding: unknown = Reflect.get(original, 'typertRemote') if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue const namespace = Reflect.get(binding, 'namespace') as string for (const candidate of remoteMethods(original)) { claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method)) } } return claims } /** * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { return this.invokePrepared(await this.prepareInvocation(request, new AbortController())) } private async invokePrepared(prepared: PreparedInvocation): Promise { if (prepared.descriptor.mode !== undefined) { throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, 'stream Remote methods must be opened through the stream carrier', ) } try { return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error) throw error } finally { // A unary call's uplink is readable only while the method runs. await prepared.invocation.close() } } /** * Open one live stream Remote method without assuming a physical carrier. * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> { return this.openStream(request, new AbortController()) } /** * `control` belongs to the logical stream: a rejected uplink item aborts it * with the Remote failure as the reason so the carrier delivers that failure. */ private async openStream(request: InvokeRemoteRequest, control: AbortController): Promise> { const prepared = await this.prepareInvocation(request, control) if (prepared.descriptor.mode === undefined) { await prepared.invocation.close() throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, 'unary Remote methods cannot be opened through the stream carrier', ) } let source: unknown try { source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { await prepared.invocation.close() if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error) throw error } if (!isIterable(source)) { await prepared.invocation.close() throw new TypertGatewayError( 'gateway/result-invalid', prepared.endpoint, 'stream Remote method did not return Iterable or AsyncIterable', { field: 'result' }, ) } return cancellableStream(source, prepared.endpoint, prepared.invocation) } private async dispatchRpc( endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope, ): Promise { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { assertPeerLive(peer, endpoint) requireInteractions(peer, endpoint) // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. if (peer.principal.kind !== 'owner') { throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'only owner Peers answer forwarded Remote events') } const result = parseRemoteEventResultPayload(payload) const client = this.remoteEventClients.get(result.clientId) if (client === undefined) { throw new Error('typert gateway: Remote event result identifies no active event stream') } if (client.peer !== peer) { throw new TypertGatewayError( 'gateway/interactions-forbidden', endpoint, 'Remote event result belongs to another Peer', ) } this.receiveRemoteEventResult(client, result) return { ok: true, value: undefined } } catch (error) { return rpcFailure(error) } } return this.invokeRpc(endpoint, payload, signal, peer) } private async openWireStream( endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal, control: AbortController, uplinkWindow?: RemoteInvocation['uplinkWindow'], ): Promise> { if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. releaseUplink(uplink) assertPeerLive(peer, endpoint) requireInteractions(peer, endpoint) return this.openRemoteEvents(payload, signal, peer) } return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink, uplinkWindow }, control) } private async *openRemoteEvents( payload: unknown, signal: AbortSignal, peer: PeerScope, ): AsyncGenerator< RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame | RemoteEventReadyFrame > { if (!isObject(payload) || !isPlainObject(payload) || Reflect.ownKeys(payload).length !== 1 || !Object.hasOwn(payload, 'args') || !isObject(payload.args) || !isPlainObject(payload.args) || Reflect.ownKeys(payload.args).length !== 0) { throw new TypertGatewayError( 'gateway/arguments-invalid', REMOTE_EVENT_STREAM_ENDPOINT, 'forwarded Remote event stream requires an empty args object', ) } const registration = this.remoteEvents if (registration === undefined) { throw new TypertGatewayError( 'gateway/service-unavailable', REMOTE_EVENT_STREAM_ENDPOINT, 'forwarded Remote event source is unavailable', ) } const lifetime = AbortSignal.any([signal, registration.lifetime.signal, peer.signal]) let clientId = randomUUID() as RemoteEventClientId while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId const client: RemoteEventClient = { peer, id: clientId, queue: new RemoteEventQueue(), signal: lifetime, deliveries: new Map(), principal: peer.principal, // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. answers: peer.principal.kind === 'owner', } this.remoteEventClients.set(clientId, client) if (client.answers) { for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) } try { yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host } yield* client.queue.iterate(lifetime) } finally { this.removeRemoteEventClient(client) } } private async consumeRemoteEvents( source: AsyncIterable, signal: AbortSignal, ): Promise { for await (const dispatch of source) { if (signal.aborted) { if ('context' in dispatch) dispatch.reject(signal.reason) return } if ('context' in dispatch) this.startRemoteEvent(dispatch) else this.broadcastRemoteEvent(dispatch) } if (!signal.aborted) { throw new Error('typert gateway: forwarded Remote event source ended unexpectedly') } } private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void { assertRemoteEventFrame(frame) const wire: RemoteEventEmitFrame = { type: 'emit', event: frame.event, args: frame.args, } let resolved = false let resources: readonly RemoteAccessResource[] | undefined for (const client of this.remoteEventClients.values()) { if (!client.answers) { // A non-owner receives the whole notification or nothing: its resources decide, payloads are never trimmed, // and a notification with invalid resources reaches no non-owner. if (!resolved) { resources = eventResources(frame.resources, frame.args) resolved = true } if (resources === undefined) continue if (this.ctx.remoteAccess.decide({ principal: client.principal, action: `event:${frame.event}`, resources }).kind === 'deny') continue } client.queue.push(wire) } } private startRemoteEvent(source: TypertRemoteEventInvocation): void { try { assertRemoteEventName(source) if (!isRemoteEventAgentId(source.context.agentId)) { throw new TypeError( 'typert gateway: scoped Remote events require a non-empty Agent identity', ) } const projected = projectRemoteEventRequest(source.request, source.context.subject) let id = randomUUID() as RemoteEventId while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId let releaseContext: () => void try { const dispose = source.context.value.effect( () => () => { this.cancelRemoteEvent( pending, new Error('typert gateway: Remote event Agent Context was released'), ) }, `api-gateway: Remote event ${JSON.stringify(source.event)}`, ) releaseContext = () => { void dispose() } } catch { source.resolve({ kind: 'next' }) return } const signals = new Set(projected.signal === undefined ? [] : [projected.signal]) const abort = (): void => { const reason: unknown = [...signals].find(signal => signal.aborted)?.reason this.cancelRemoteEvent(pending, reason instanceof Error ? reason : new Error('typert gateway: Remote event was cancelled', { cause: reason })) } const pending: PendingRemoteEvent = { id, source, frame: { type: 'waterfall', event: source.event, eventId: id, agentId: source.context.agentId, request: projected.request, }, deliveries: new Set(), releaseContext, releaseSignal: () => { for (const signal of signals) signal.removeEventListener('abort', abort) }, } this.pendingRemoteEvents.set(id, pending) for (const signal of signals) signal.addEventListener('abort', abort, { once: true }) if ([...signals].some(signal => signal.aborted)) abort() else { for (const client of this.remoteEventClients.values()) { if (client.answers) this.deliverRemoteEvent(pending, client) } } } catch (error) { source.reject(error) } } private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void { pending.deliveries.add(client) client.deliveries.set(pending.id, pending) client.queue.push(pending.frame) } private receiveRemoteEventResult( client: RemoteEventClient, result: ReturnType, ): void { const pending = this.pendingRemoteEvents.get(result.eventId) // Settlement and Client replacement may race the result request. Results // from a completed event or a superseded delivery are idempotent no-ops. if (pending === undefined || !pending.deliveries.has(client)) return this.removeRemoteEventDelivery(pending, client) if (result.outcome.kind === 'result') { this.settleRemoteEvent(pending, { kind: 'result', value: result.outcome.value, }) } else if (result.outcome.kind === 'rejected') { this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error)) } else if (pending.deliveries.size === 0) { this.settleRemoteEvent(pending, { kind: 'next' }) } } private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void { pending.deliveries.delete(client) client.deliveries.delete(pending.id) } private removeRemoteEventClient(client: RemoteEventClient): void { this.remoteEventClients.delete(client.id) for (const pending of [...client.deliveries.values()]) this.removeRemoteEventDelivery(pending, client) client.queue.end() } private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void { this.finishRemoteEvent(pending) pending.source.resolve(outcome) } private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void { if (this.pendingRemoteEvents.get(pending.id) !== pending) return this.finishRemoteEvent(pending) pending.source.reject(reason) } private finishRemoteEvent(pending: PendingRemoteEvent): void { this.pendingRemoteEvents.delete(pending.id) pending.releaseSignal() pending.releaseContext() const clients = new Set(pending.deliveries) for (const client of clients) this.removeRemoteEventDelivery(pending, client) const cancellation: RemoteEventCancellationFrame = { type: 'cancel', eventId: pending.id, } for (const client of clients) client.queue.push(cancellation) } private closeRemoteEvents(reason: unknown): void { for (const pending of [...this.pendingRemoteEvents.values()]) { this.cancelRemoteEvent(pending, reason) } for (const client of [...this.remoteEventClients.values()]) client.queue.end() } private async invokeRpc( endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope, ): Promise { try { const prepared = await this.prepareInvocation( remoteRequest(endpoint, payload, signal, peer), new AbortController(), ) const value = await this.invokePrepared(prepared) // A void or explicitly absent business result carries no `value` field; // JSON has no `undefined`, and the envelope's optional slot is the one // representation of absence that both args and results already use. return encodeRpcResult(value, prepared.descriptor.result) } catch (error) { return rpcFailure(error) } } /** `control` fails the logical stream when an uplink item is rejected; unary calls hand over an inert one. */ private async prepareInvocation( request: InvokeRemoteRequest, control: AbortController, ): Promise { const endpoint = endpointOf(request.namespace, request.method) assertPeerLive(request.peer, endpoint) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) const validated = validateArguments(descriptor, request.args, endpoint) // Access precedes lookup resolution, so a refused call activates nothing. // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. if (request.peer.principal.kind !== 'owner') { const resources = callResources( this.ctx.typert, this.resourceReaders(descriptor), descriptor, validated, request.signal ?? NEVER_ABORTED_SIGNAL, ) const decision: RemoteAccessDecision = resources === undefined ? { kind: 'deny', reason: 'resource-invalid' } : this.ctx.remoteAccess.decide({ principal: request.peer.principal, action: `${descriptor.namespace}/${descriptor.method}`, resources, args: validated, }) if (decision.kind === 'deny') { throw new RemoteError('remote-access/denied', `remote access: ${endpoint}: access denied (${decision.reason})`, { action: endpoint, reason: decision.reason, }) } } const receiverContext = await this.resolveReceiverContext(descriptor, validated, endpoint) const receiver: unknown = receiverContext.get(descriptor.service) if (!isObject(receiver)) { throw new TypertGatewayError( 'gateway/service-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} is unavailable`, ) } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, validated, endpoint))) const signal = methodSignal(request, control, descriptor.mode === 'stream') const invocation = new GatewayInvocation( { namespace: request.namespace, method: request.method, args: request.args }, descriptor.service, request.peer, signal, { source: request.uplink ?? EMPTY_ASYNC_ITERABLE, window: request.uplinkWindow, codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC, endpoint, abort: (reason) => { control.abort(reason) }, }, ) if (descriptor.cancellation !== undefined) args.push(signal) // The method runs on a Service view bound to a Context carrying this call: // Cordis rebinds `this.ctx` to the accessing Context, so `this.ctx.invocation` // is this call and nothing travels through the parameter list. The view // resolves the Service the plain read above already found. const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object const implementation = descriptor.implementation ?? descriptor.method const method: unknown = Reflect.get(callReceiver, implementation) if (typeof method !== 'function') { throw new TypertGatewayError( 'gateway/method-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) } return { endpoint, descriptor, receiver: callReceiver, args, method: method as (...args: never[]) => unknown, invocation, } } private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { const strict = this.ctx.typert.local.get(endpoint) if (strict !== undefined) return strict if (this.ctx.typert.local.hasSeen(endpoint)) { throw new TypertGatewayError( 'gateway/definition-unavailable', endpoint, 'its strict definition was withdrawn and SRC fallback is forbidden', ) } return this.resolveSrcDescriptor(namespace, method, endpoint) } private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { const candidates: InvocationDescriptor[] = [] for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { if (definition.type !== 'service') continue const receiver: unknown = this.ctx.get(serviceKey) if (!isObject(receiver)) continue const original = originalOf(receiver) const value: unknown = Reflect.get(original, 'typertRemote') if (value === undefined) continue const binding = readBinding(value, original, serviceKey, endpoint) if (binding.namespace !== namespace) continue const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method) if (marker === undefined) continue candidates.push(this.srcDescriptor(binding, marker, method, endpoint)) } if (candidates.length === 0) { throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'no active Remote method exports this endpoint') } if (candidates.length > 1) { throw new TypertGatewayError( 'gateway/ambiguous-endpoint', endpoint, `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`, ) } return candidates[0] as InvocationDescriptor } private srcDescriptor( binding: TypertGatewayBinding, marker: ReturnType[number], method: string, endpoint: string, ): InvocationDescriptor { const names = methodParameterNames(binding.service, marker.method, endpoint) const signalIndex = names.indexOf('signal') if (signalIndex >= 0 && signalIndex !== names.length - 1) { throw new TypertGatewayError( 'gateway/signature-invalid', endpoint, 'SRC cancellation parameter signal must be the final parameter', { field: 'signal' }, ) } const cancellation = signalIndex >= 0 ? { parameter: 'signal' as const } : undefined const businessNames = cancellation === undefined ? names : names.slice(0, -1) const parameters: InvocationParameterDescriptor[] = [] const wires = new Set() for (const name of businessNames) { const matches = this.ctx.typert.lookups.definitions() .filter(definition => definition.parameter === name) if (matches.length > 1) { throw new TypertGatewayError( 'gateway/signature-invalid', endpoint, `parameter ${JSON.stringify(name)} matches multiple lookup providers`, { field: name }, ) } const match = matches[0] const parameter: InvocationParameterDescriptor = match === undefined ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } } : { name, wire: match.wire, source: 'lookup', lookup: match.key, codec: { mode: 'src-json' }, } if (wires.has(parameter.wire)) { throw new TypertGatewayError( 'gateway/signature-invalid', endpoint, `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, { field: parameter.wire }, ) } wires.add(parameter.wire) parameters.push(parameter) } let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' } if (marker.invocation.kind === 'context') { const provider = this.ctx.typert.contexts.getHost(marker.invocation.context) if (provider === undefined) { throw new TypertGatewayError( 'gateway/context-unavailable', endpoint, `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`, ) } if (wires.has(provider.wire)) { throw new TypertGatewayError( 'gateway/signature-invalid', endpoint, `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, { field: provider.wire }, ) } receiver = { kind: 'context', context: marker.invocation.context, wire: provider.wire, codec: { mode: 'src-json' }, } } return { id: `src:${binding.serviceKey}#${endpoint}`, service: binding.serviceKey, namespace: binding.namespace, method, ...(marker.method === method ? {} : { implementation: marker.method }), ...(marker.mode === undefined ? {} : { mode: marker.mode }), invocation: receiver, parameters, ...(cancellation === undefined ? {} : { cancellation }), result: { mode: 'src-json' }, } } private async resolveReceiverContext( descriptor: InvocationDescriptor, args: Readonly>, endpoint: string, ): Promise { if (descriptor.invocation.kind === 'direct') return this.ctx const invocation = descriptor.invocation const provider = this.ctx.typert.contexts.getHost(invocation.context) if (provider === undefined) { throw new TypertGatewayError( 'gateway/context-unavailable', endpoint, `Context provider ${JSON.stringify(invocation.context)} is unavailable`, ) } if (provider.wire !== invocation.wire || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) { throw new TypertGatewayError( 'gateway/provider-mismatch', endpoint, `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, { field: invocation.wire }, ) } const identity = decode(invocation.codec, args[invocation.wire], endpoint, invocation.wire) let context: Context | undefined try { context = await provider.resolve(identity) } catch (cause) { if (remoteErrorOf(cause) !== undefined) throw cause throw new TypertGatewayError( 'gateway/context-failed', endpoint, `Context provider ${JSON.stringify(invocation.context)} failed`, { cause, field: invocation.wire }, ) } if (context === undefined) { throw new TypertGatewayError( 'gateway/context-not-found', endpoint, `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, { field: invocation.wire }, ) } return context } private async resolveParameter( parameter: InvocationParameterDescriptor, args: Readonly>, endpoint: string, ): Promise { // Lookup ids are never omissible, so an absent field belongs to a json parameter that takes undefined. if (!Object.hasOwn(args, parameter.wire)) return undefined if (parameter.source === 'json') return args[parameter.wire] const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire) const key = parameter.lookup /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { throw new TypertGatewayError( 'gateway/lookup-unavailable', endpoint, `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, { field: parameter.wire }, ) } const provider = this.ctx.typert.lookups.get(key) if (provider === undefined) { throw new TypertGatewayError( 'gateway/lookup-unavailable', endpoint, `lookup provider ${JSON.stringify(key)} is unavailable`, { field: parameter.wire }, ) } if (provider.wire !== parameter.wire || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) { throw new TypertGatewayError( 'gateway/provider-mismatch', endpoint, `lookup provider ${JSON.stringify(key)} does not match its strict definition`, { field: parameter.wire }, ) } let resolved: unknown try { resolved = await provider.resolve(value) } catch (cause) { if (remoteErrorOf(cause) !== undefined) throw cause throw new TypertGatewayError( 'gateway/lookup-failed', endpoint, `lookup provider ${JSON.stringify(key)} failed`, { cause, field: parameter.wire }, ) } if (resolved === undefined) { throw new TypertGatewayError( 'gateway/lookup-not-found', endpoint, `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, { field: parameter.wire }, ) } return resolved } } function encodeRpcResult(value: unknown, codec: TypertCodec): ConnectionRpcResult { const attachments: ConnectionRpcAttachment[] = [] const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => { attachments.push({ path: [...path], bytes }) return null } const encoded = codec.mode === 'strict' ? codec.encode?.(value, writeBytes) ?? value : encodeRuntimeResult(value, writeBytes) return { ok: true, value: encoded, ...(attachments.length === 0 ? {} : { attachments }) } } function encodeRuntimeResult( input: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null, ): unknown { const path: (string | number)[] = [] const ancestors = new Set() const extract = (input: unknown, key: string): unknown => { // Capture accessors and toJSON once, before materializing the JSON metadata. let value = input if (input !== null && typeof input === 'object' && !(input instanceof Uint8Array)) { const toJSON: unknown = Reflect.get(input, 'toJSON') if (typeof toJSON === 'function') value = Reflect.apply(toJSON, input, [key]) } if (value instanceof Uint8Array) return writeBytes(value, path) if (typeof value !== 'object' || value === null) return value if (value instanceof Number || value instanceof String || value instanceof Boolean) return value.valueOf() if (ancestors.has(value)) throw new TypeError('gateway: circular RPC result') ancestors.add(value) let copy: object if (Array.isArray(value)) { const items: unknown[] = [] // JSON arrays include every index, even holes and non-enumerable elements. for (let index = 0, length = value.length; index < length; index++) items.push(child(value[index], index)) copy = items } else { const fields: Record = {} for (const key of Object.keys(value)) { const item: unknown = Reflect.get(value, key) // A toJSON method on the projected object must not run a second time. if (key === 'toJSON' && typeof item === 'function') continue const extracted = child(item, key) if (key === '__proto__') Object.defineProperty(fields, key, { value: extracted, enumerable: true }) else fields[key] = extracted } copy = fields } ancestors.delete(value) return copy } const child = (value: unknown, key: string | number): unknown => { if (typeof value !== 'object' || value === null) return value path.push(key) const extracted = extract(value, String(key)) path.pop() return extracted } return extract(input, 'value') } type RemoteEventWireFrame = | RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame /** Pull-driven queue owned by one connected Client event generation. */ class RemoteEventQueue { private readonly frames = new Deque() private waiter: (() => void) | undefined private closed = false push(frame: RemoteEventWireFrame): void { if (this.closed) return this.frames.pushBack(frame) this.waiter?.() } end(): void { if (this.closed) return this.closed = true this.waiter?.() } /** * Yield buffered and later frames until the queue ends or `signal` aborts. * An abort discards the frames still buffered, so a revoked Peer receives none of them. * @param signal - Client generation lifetime. * @returns the queued wire frames in push order. */ async *iterate(signal: AbortSignal): AsyncGenerator { const abort = (): void => { this.end() } signal.addEventListener('abort', abort, { once: true }) try { while (true) { while (this.frames.size > 0 && !signal.aborted) yield this.frames.popFront() as RemoteEventWireFrame if (this.closed || signal.aborted) return await new Promise((resolve) => { this.waiter = resolve }) this.waiter = undefined } } finally { signal.removeEventListener('abort', abort) } } } function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void { assertRemoteEventName(frame) if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) { throw new TypeError(`typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`) } } function assertRemoteEventName(frame: { readonly event: unknown }): void { if (typeof frame.event !== 'string' || frame.event.length === 0) { throw new TypeError('typert gateway: Remote event name must be a nonempty string') } } function requireInteractions(peer: PeerScope, endpoint: string): void { if (peer.interactions !== 'answer') { throw new TypertGatewayError( 'gateway/interactions-forbidden', endpoint, 'forwarded Remote events require interactions=answer', ) } } function parseRemoteEventResultPayload(payload: unknown): ReturnType { if (!isObject(payload) || !isPlainObject(payload) || Reflect.ownKeys(payload).length !== 1 || !Object.hasOwn(payload, 'args')) { throw new Error('typert gateway: Remote event result requires exactly one plain-object args field') } return parseRemoteEventResult(payload.args) } function remoteRequest( endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope, ): InvokeRemoteRequest { // A disposed Peer is refused whatever it sent. assertPeerLive(peer, endpoint) const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) } const [namespace, method] = segments as [string, string] if (!isObject(payload) || !isPlainObject(payload) || Reflect.ownKeys(payload).length !== 1 || !Object.hasOwn(payload, 'args') || !isObject(payload.args) || !isPlainObject(payload.args)) { throw new Error('Remote payload must contain exactly one plain-object args field') } return { namespace, method, args: payload.args, signal, peer } } /** * The signal a method observes. A carrier that supplies an uplink fails the * stream through `control` when an item is rejected, so that invocation joins * `control` with the carrier signal; a stream also ends with its Peer. Every * other invocation keeps the carrier signal's identity. */ function methodSignal(request: InvokeRemoteRequest, control: AbortController, stream: boolean): AbortSignal { const carrier = request.signal if (request.uplink === undefined && !stream) return carrier ?? NEVER_ABORTED_SIGNAL const joined = carrier === undefined || carrier === control.signal ? [control.signal] : [carrier, control.signal] // `AbortSignal.any` leaves each source only a weak reference to the joined signal, // so per-stream joins on a Host-lifetime Peer signal accumulate nothing. return AbortSignal.any(stream ? [...joined, request.peer.signal] : joined) } /** * Refuse a call whose Peer has been disposed. * @param peer - Peer the call speaks for. * @param endpoint - endpoint named in the failure. * @throws {@link TypertGatewayError} `gateway/peer-disposed` once the Peer is disposed. */ function assertPeerLive(peer: PeerScope, endpoint: string): void { if (peer.signal.aborted) { throw new TypertGatewayError('gateway/peer-disposed', endpoint, 'the calling Peer has been disposed') } } function isIterable(value: unknown): value is Iterable | AsyncIterable { return isObject(value) && (typeof Reflect.get(value, Symbol.iterator) === 'function' || typeof Reflect.get(value, Symbol.asyncIterator) === 'function') } async function *cancellableStream( source: Iterable | AsyncIterable, endpoint: string, invocation: GatewayInvocation, ): AsyncGenerator { const { signal } = invocation const asyncFactory: unknown = Reflect.get(source, Symbol.asyncIterator) const syncFactory: unknown = Reflect.get(source, Symbol.iterator) const iterator = typeof asyncFactory === 'function' ? Reflect.apply(asyncFactory, source, []) as AsyncIterator : Reflect.apply(syncFactory as (...args: never[]) => Iterator, source, []) let rejectAbort: ((error: unknown) => void) | undefined const onAbort = (): void => { rejectAbort?.(streamAbortFailure(endpoint, signal.reason)) } signal.addEventListener('abort', onAbort, { once: true }) try { while (true) { if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason) // Reusing a pending cancellation promise retains every completed race. const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) // next() can abort and throw synchronously before the race subscribes. void aborted.catch(() => undefined) const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) rejectAbort = undefined if (next.done === true) return yield next.value } } finally { rejectAbort = undefined signal.removeEventListener('abort', onAbort) // The uplink closes first so a method blocked on `uplink.next()` unwinds // before its iterator is asked to return. await invocation.close() await iterator.return?.() } } /** * The failure a stream surfaces for its abort: a Remote failure used as the * reason is the Gateway or carrier failing the stream itself (a rejected, * overflowing, or misplaced uplink item); any other abort is a cancellation. */ function streamAbortFailure(endpoint: string, reason: unknown): unknown { return remoteErrorOf(reason) === undefined ? remoteCancelled(endpoint, reason) : reason } /** Carrier-signal cancellation as the shared failure vocabulary expresses it. */ function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> { return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause }) } /** * The iterable `invocation.uplink()` returns. Each uplink item passes the * descriptor codec, or the JSON-safety check when the descriptor declares no * uplink, before delivery; a rejected item fails the whole logical stream. * Iteration ends when the Client half-closes or the downlink finishes, and * fails when the stream is cancelled, so a method blocked on the uplink always * wakes. */ class UplinkDecoder implements AsyncIterable, AsyncIterator { private readonly source: AsyncIterator private readonly interrupted = new Set>>() private readonly onAbort = (): void => { const failure = streamAbortFailure(this.endpoint, this.signal.reason) for (const read of this.interrupted) read.reject(failure) } private closed = false constructor( uplink: AsyncIterable, private readonly codec: TypertCodec, private readonly endpoint: string, private readonly signal: AbortSignal, private readonly abort: (reason: unknown) => void, ) { this.source = uplink[Symbol.asyncIterator]() signal.addEventListener('abort', this.onAbort, { once: true }) } [Symbol.asyncIterator](): AsyncIterator { return this } async next(): Promise> { // A cancelled stream reports the cancellation on every read, closed or not. if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason) if (this.closed) return UPLINK_DONE // Only pending reads belong to the decoder; finish() must wake all of them. const interrupted = Promise.withResolvers>() this.interrupted.add(interrupted) // The source can abort and throw synchronously before the race subscribes. void interrupted.promise.catch(() => undefined) let next: IteratorResult try { next = await Promise.race([this.source.next(), interrupted.promise]) } finally { this.interrupted.delete(interrupted) } if (next.done === true) { this.finish() return UPLINK_DONE } let value: unknown try { // A top-level `undefined` is the absent `value` of its frame: without a codec it is the item itself. value = next.value === undefined && this.codec.mode === 'src-json' ? undefined : decode(this.codec, next.value, this.endpoint, 'uplink') } catch (failure) { // The codec failure (`gateway/input-invalid`, field `uplink`) fails the whole logical stream. this.abort(failure) throw failure } return { value, done: false } } /** Downlink finished or the method stopped reading: unread uplink items are dropped. */ return(): Promise> { if (!this.closed) { this.finish() // The carrier owns the source iterator; a generator blocked in next() // completes this return once it yields, so it is not awaited here. void Promise.resolve().then(() => this.source.return?.()).catch(() => undefined) } return Promise.resolve(UPLINK_DONE) } private finish(): void { this.closed = true this.signal.removeEventListener('abort', this.onAbort) for (const read of this.interrupted) read.resolve(UPLINK_DONE) } } /** * The context of one Remote call as the receiving method reads it through * `this.ctx.invocation`. The uplink is decoded on first use and released when * the call's downlink finishes. */ class GatewayInvocation implements RemoteInvocation { private decoder: UplinkDecoder | undefined private taken = false get uplinkWindow(): RemoteInvocation['uplinkWindow'] { return this.uplink_.window } /** * @param request - decoded endpoint and wire arguments. * @param service - Cordis service key of the receiver. * @param peer - Peer the call speaks for. * @param signal - the signal the method observes. * @param uplink - carrier items and the codec that decodes them. */ constructor( readonly request: RemoteInvocation['request'], readonly service: string, readonly peer: PeerScope, readonly signal: AbortSignal, private readonly uplink_: UplinkSource, ) {} uplink(): AsyncIterable { if (this.taken) { throw new Error(`typert gateway: ${this.uplink_.endpoint}: invocation.uplink() is available once per call`) } this.taken = true const { source, codec, endpoint, abort } = this.uplink_ this.decoder = new UplinkDecoder(source, codec, endpoint, this.signal, abort) // The descriptor codec decides what arrives; `In` is the caller's assertion. return this.decoder as AsyncIterable } /** * The downlink finished: release the uplink. Unread items are dropped, and a * carrier iterable the method never took is returned so it stops producing; * a later `uplink()` throws like a second one would. * @returns settles once a taken uplink has closed. */ async close(): Promise { if (this.decoder !== undefined) { await this.decoder.return() return } this.taken = true releaseUplink(this.uplink_.source) } } /** * Return a carrier uplink nobody will read, so it drops later items instead of * buffering them. The carrier owns the iterator and `return()` is not awaited: * a generator blocked in `next()` completes it only once it yields. * @param source - the carrier's uplink iterable. */ function releaseUplink(source: AsyncIterable): void { void Promise.resolve().then(() => source[Symbol.asyncIterator]().return?.()).catch(() => undefined) } function rpcFailure(error: unknown): ConnectionRpcResult { const remote = remoteErrorOf(error) if (remote !== undefined) { return { ok: false, error: { code: remote.code, message: remote.message, details: remote.details } } } return { ok: false, error: { code: 'gateway/internal', message: error instanceof Error ? error.message : String(error), details: {}, }, } } function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure { return (rpcFailure(error) as Extract).error } function endpointOf(namespace: string, method: string): string { return `${namespace}/${method}` } function validateBinding( receiver: object, serviceKey: string, namespace: string, endpoint: string, ): ResolvedBinding { const original = originalOf(receiver) const value: unknown = Reflect.get(original, 'typertRemote') if (value === undefined) { throw new TypertGatewayError( 'gateway/binding-invalid', endpoint, `Service ${JSON.stringify(serviceKey)} has no visible typertRemote binding`, ) } return { binding: readBinding(value, original, serviceKey, endpoint, namespace), original, } } function readBinding( value: unknown, original: object, serviceKey: string, endpoint: string, namespace?: string, ): TypertGatewayBinding { if (!isObject(value) || Reflect.get(value, 'service') !== original || Reflect.get(value, 'serviceKey') !== serviceKey || typeof Reflect.get(value, 'namespace') !== 'string' || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) { throw new TypertGatewayError( 'gateway/binding-invalid', endpoint, `Service ${JSON.stringify(serviceKey)} has an inconsistent typertRemote binding`, ) } return value as TypertGatewayBinding } function originalOf(receiver: object): object { const original: unknown = Reflect.get(receiver, symbols.original) return isObject(original) ? original : receiver } function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] { let prototype: object | null = Object.getPrototypeOf(service) as object | null let implementation: ((this: object, ...args: never[]) => unknown) | undefined while (prototype !== null) { const descriptor = Object.getOwnPropertyDescriptor(prototype, method) if (descriptor !== undefined) { if ('value' in descriptor && typeof descriptor.value === 'function') { implementation = descriptor.value as (this: object, ...args: never[]) => unknown } break } prototype = Object.getPrototypeOf(prototype) as object | null } if (implementation === undefined) { throw new TypertGatewayError( 'gateway/method-unavailable', endpoint, `Remote marker has no prototype method ${JSON.stringify(method)}`, ) } const source = Function.prototype.toString.call(implementation) const open = source.indexOf('(') const close = source.indexOf(')', open + 1) /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */ if (open < 0 || close < 0) return invalidSignature(endpoint, method) const body = source.slice(open + 1, close).trim() if (body.length === 0) return [] const parts = body.split(',').map(part => part.trim()) const names = new Set() for (const part of parts) { if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method) names.add(part) } return [...names] } function invalidSignature(endpoint: string, method: string): never { throw new TypertGatewayError( 'gateway/signature-invalid', endpoint, `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`, ) } function assertExactArguments( args: Readonly>, descriptor: InvocationDescriptor, endpoint: string, ): void { if (!isPlainObject(args)) { throw new TypertGatewayError('gateway/arguments-invalid', endpoint, 'args must be a plain object') } const expected = new Set(descriptor.parameters.map(parameter => parameter.wire)) if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire) const actual = Reflect.ownKeys(args) const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key)) // A JSON field may be omitted when the strict descriptor declares absence, // and always under SRC: a weak descriptor reads parameter names from the // JavaScript signature and cannot see which are optional, so LIB is where an // omitted required argument is caught. Lookup ids are never omissible. const acceptsMissing = new Set(descriptor.parameters .filter(parameter => parameter.source === 'json' && (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json')) .map(parameter => parameter.wire)) const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key)) if (extra.length === 0 && missing.length === 0) return const clauses: string[] = [] if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`) if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`) throw new TypertGatewayError('gateway/arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) } /** * Decode every present JSON argument once through its parameter codec; a * Typert codec drops object fields its compiled type does not declare. Lookup * and Context identities keep their wire values; dispatch decodes and resolves * them only after the access check. An absent field stays absent; a present * `undefined` is not JSON-safe and fails decoding. */ function validateArguments( descriptor: InvocationDescriptor, args: Readonly>, endpoint: string, ): Readonly> { const entries: [string, unknown][] = [] if (descriptor.invocation.kind === 'context') entries.push([descriptor.invocation.wire, args[descriptor.invocation.wire]]) for (const parameter of descriptor.parameters) { if (!Object.hasOwn(args, parameter.wire)) continue const value = args[parameter.wire] entries.push([parameter.wire, parameter.source === 'json' ? decode(parameter.codec, value, endpoint, parameter.wire) : value]) } return Object.fromEntries(entries) } function decode( codec: TypertCodec, value: unknown, endpoint: string, field: string, ): unknown { try { if (codec.mode === 'strict') { value = codec.create().parse(value) /* v8 ignore next -- generated optional-input codecs are the only strict codecs that return undefined. */ if (value === undefined) return value } assertJsonValue(value, new Set()) return value } catch (cause) { throw new TypertGatewayError( 'gateway/input-invalid', endpoint, `wire field ${JSON.stringify(field)} failed boundary validation`, { cause, field }, ) } } function assertJsonValue(value: unknown, ancestors: Set): void { if (value === null || typeof value === 'string' || typeof value === 'boolean') return if (typeof value === 'number') { if (Number.isFinite(value)) return throw new TypeError('non-finite number is not JSON-safe') } if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`) if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe') ancestors.add(value) try { if (Array.isArray(value)) { if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) { throw new TypeError('sparse or decorated array is not JSON-safe') } for (let index = 0; index < value.length; index += 1) { if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe') assertJsonValue(value[index], ancestors) } return } if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe') if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe') for (const key of Reflect.ownKeys(value)) { const descriptor = Object.getOwnPropertyDescriptor(value, key) /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */ if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { throw new TypeError('non-data property is not JSON-safe') } assertJsonValue(descriptor.value, ancestors) } } finally { ancestors.delete(value) } } function isPlainObject(value: object): value is Record { if (Array.isArray(value)) return false const prototype = Object.getPrototypeOf(value) as object | null return prototype === null || prototype === Object.prototype } function isObject(value: unknown): value is object { return (typeof value === 'object' && value !== null) || typeof value === 'function' } export default TypertGatewayService