Files
deepseek-harness/packages/api/gateway/src/index.ts
T
imccyu a1473ddec6 feat(gateway): expose pending remote events
Session state reads sample Gateway pending events; the Gateway contract now
provides getPendingRemoteEvents() for that read.
2026-10-25 18:14:17 +08:00

1750 lines
67 KiB
TypeScript

/**
* 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<unknown>
/** 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<void>
readonly host: RemoteEventHostInfo
}
interface RemoteEventClient {
readonly peer: PeerScope
readonly id: RemoteEventClientId
readonly queue: RemoteEventQueue
readonly signal: AbortSignal
readonly deliveries: Map<RemoteEventId, PendingRemoteEvent>
/** 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<RemoteEventClient>
releaseContext: () => void
releaseSignal: () => void
}
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['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<never> = {
[Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }),
}
const UPLINK_DONE: IteratorReturnResult<undefined> = { 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<TypertGatewayErrorCode> {
/** Canonical `<namespace>/<method>` 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<Config> = 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<string> | undefined
private remoteEvents: RegisteredRemoteEventSource | undefined
private readonly remoteEventClients = new Map<RemoteEventClientId, RemoteEventClient>()
private readonly pendingRemoteEvents = new Map<RemoteEventId, PendingRemoteEvent>()
/**
* 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<Promise<void>>()
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<void> {
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<string> {
const claims = new Set<string>()
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<unknown> {
return this.invokePrepared(await this.prepareInvocation(request, new AbortController()))
}
private async invokePrepared(prepared: PreparedInvocation): Promise<unknown> {
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<AsyncIterable<unknown>> {
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<AsyncIterable<unknown>> {
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<ConnectionRpcResult> {
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<unknown>,
peer: PeerScope,
signal: AbortSignal,
control: AbortController,
uplinkWindow?: RemoteInvocation['uplinkWindow'],
): Promise<AsyncIterable<unknown>> {
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<TypertRemoteEventDispatch>,
signal: AbortSignal,
): Promise<void> {
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<typeof parseRemoteEventResult>,
): 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<ConnectionRpcResult> {
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<PreparedInvocation> {
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<typeof remoteMethods>[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<string>()
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<Record<string, unknown>>,
endpoint: string,
): Promise<Context> {
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<Record<string, unknown>>,
endpoint: string,
): Promise<unknown> {
// 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<object>()
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<string, unknown> = {}
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<RemoteEventWireFrame>()
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<RemoteEventWireFrame> {
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<void>((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<typeof parseRemoteEventResult> {
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<unknown> | AsyncIterable<unknown> {
return isObject(value)
&& (typeof Reflect.get(value, Symbol.iterator) === 'function'
|| typeof Reflect.get(value, Symbol.asyncIterator) === 'function')
}
async function *cancellableStream(
source: Iterable<unknown> | AsyncIterable<unknown>,
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<unknown>
: Reflect.apply(syncFactory as (...args: never[]) => Iterator<unknown>, 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<never>((_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<unknown>, AsyncIterator<unknown> {
private readonly source: AsyncIterator<unknown>
private readonly interrupted = new Set<PromiseWithResolvers<IteratorResult<unknown>>>()
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<unknown>,
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<unknown> {
return this
}
async next(): Promise<IteratorResult<unknown>> {
// 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<IteratorResult<unknown>>()
this.interrupted.add(interrupted)
// The source can abort and throw synchronously before the race subscribes.
void interrupted.promise.catch(() => undefined)
let next: IteratorResult<unknown>
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<IteratorResult<unknown>> {
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<In = unknown>(): AsyncIterable<In> {
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<In>
}
/**
* 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<void> {
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<unknown>): 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<ConnectionRpcResult, { readonly ok: false }>).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<string>()
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<Record<string, unknown>>,
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<Record<string, unknown>>,
endpoint: string,
): Readonly<Record<string, unknown>> {
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<object>): 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<string, unknown> {
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