Files

description, kind
description kind
Typed Client-to-Host calls and streams: dispatch, validation, cancellation, reconnection, and forwarded Host events. package-reference

@deepseek-ai/dsh-api-gateway

English | 中文

Summary

Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides ctx.typertGateway, while @deepseek-ai/dsh-api-gateway/client provides ctx.remote; both consume the same generated InvocationDescriptor contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams, each carrying a Client-to-Host uplink on the same logical stream.

Table of Contents


Host service: TypertGatewayService (ctx key: typertGateway)

ctx.typertGateway.invoke() resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, and invokes the public business method. Business Services extend TypertRemoteService and mark methods with @Remote or @RemoteScope from dsh-typert-protocol; bindTypertRemote() remains available when another base class owns inheritance.

Strict mode reads generated invocation descriptors from ctx.typert.local. Lookup parameters use the currently active resolver in ctx.typert.lookups: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped configure(); @RemoteScope resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. For unary results, Gateway executes the generated codec's encode() when present and otherwise keeps strict JSON values unchanged; SRC recursively detects runtime byte values. Both paths return JSON-compatible metadata and result-relative byte attachments to Connection for transport framing.

The Host entry registers a trusted-host interceptor on Connection's shared /api FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct invoke() calls preserve business errors; TypertGatewayError is a RemoteError subclass whose gateway/* codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: gateway/uplink-overflow for an uplink whose buffered frames exceed streamInboxBytes, gateway/protocol for an uplink item after end, and the codec code gateway/input-invalid for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own RemoteError, and the code it chose reaches the caller unchanged.

A cancellation-aware Remote method declares signal: AbortSignal as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global AbortSignal type.

A stream Remote uses @Remote({ mode: 'stream' }) and returns an Iterable or AsyncIterable. ctx.typertGateway.stream() applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned /api/remote.mux WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured websocketHeartbeatIntervalMs interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.

When the launcher supplies ctx.appReady, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without appReady register immediately and own their startup ordering; in-process invocation is unchanged.

Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, RemoteStream<Out, In> from dsh-typert-protocol; In defaults to never, and a method that returns Iterable, AsyncIterable, or RemoteStream<Out> declares that it reads none. The running method reads its call as this.ctx.invocation: the Gateway derives the receiver from a Context carrying that RemoteInvocation — request, service, peer, signal, and uplink() — so nothing enters the parameter list, and ctx.invocation is undefined on a Context no Remote call derived. uplink() is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose In is never — it delivers JSON-safe unknown values. The Client sends each item as an item frame on the open logical stream, a top-level undefined item as an item frame without value, and its half-close as an end frame; the method observes the half-close as the end of the uplink() iteration and cancellation through signal. A rejected item fails the whole stream with gateway/input-invalid (field uplink). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most streamInboxBytes (262144 by default) of uplink frame bytes; overflow fails the stream with gateway/uplink-overflow, and an item after end fails it with gateway/protocol, in both cases without closing the socket. item, end, and cancel frames for a stream id the Host has already finished are dropped; only a duplicate open closes the socket. Gateway-owned streams such as $events return their uplink as soon as they open, so their item frames are dropped instead of buffered. A unary method may also call uplink(); its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly.

Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads.

Host composition can register one application event source through registerRemoteEvents(). Gateway reserves the internal $events logical endpoint for that source, accepts only empty args, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening { type: 'ready', clientId, host: { home } } frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready.

Client service: ClientRemote (ctx key: remote)

The browser carrier accepts the shell-owned stream origin described by Connection; logical stream framing and lifecycle remain unchanged.

ctx.remote.$mount() validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced remote.<namespace> child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable.

Each unary call checks positional arity, constructs the descriptor's exact named args, and sends the typed values through ctx.connection.rpc.call('/api', endpoint, ...) without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a RemoteStreamHandle<Out, In> from dsh-typert-protocol and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. send(item) queues an uplink item, sent after the open frame; end() half-closes the uplink; dispose() sends cancel unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates send throws and end is ignored. Generated cancellation-aware methods accept a final optional AbortSignal; the Client combines it with the contribution mount lifetime before invoking the carrier. The Client delegates unary result conversion to the codec's optional decode(). Generated decoders validate nested metadata and native byte types without iterating, copying, or freezing byte payloads. JSON unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject.

Every unary call resolves to RemoteResult<T> — { ok: true, value } or { ok: false, error } — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers gateway/cancelled when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. error is a live RemoteError instance, so throw result.error keeps throw semantics, and isRemoteFailure(value) is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. carrierFailure(endpoint, error) and cancelledFailure(endpoint, cause) build those two folded results, so a test stand-in for this face folds identically.

ctx.remote.$host reads the fixed Host facts as plain values: home (undefined until the first ready frame) and isLoopback. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for connection/reset instead of polling it.

ctx.remote.$stream() returns a single-consumer RemoteStream spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a RemoteError, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. RemoteStreamCarrierError names a retryable physical loss and reaches a domain only as the carrierFailed callback argument, never as a terminal outcome. RemoteSnapshotStream adds one opening snapshot followed by deltas. RemoteJournalStream adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. A domain may also carry cursorless notifications: they never advance or repair the durable cursor, and notifications received during gap repair publish only after the replacement page commits. If a newer generation supersedes that repair, held notifications from the superseded generation are discarded with its page. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped.

ctx.remote.$on() subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis Events declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the $events pump as a Connection generation source when it activates, whether any $on listener exists. Browsers use Remote mux, while in-process compositions use connection.rpc.open; the opening ready item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under continuous, capped jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call next(), or reject; Gateway returns that outcome through the existing HTTP unary carrier.

Client waterfall Context resolution stays synchronous. A resolver can return a borrowed Context or TypertOwnedValue<Context>; Gateway releases the owned value only after handler use and reply settlement. Context-resolution failures retain the existing report-and-delegate behavior, while handler failures produce rejected replies. Cancellation suppresses late replies without releasing a Context still used by the handler. Every handler must observe request.signal and settle after cancellation; plugin disposal and Connection generation replacement wait for outstanding handlers to settle. Session Context acquisition itself performs no history I/O.

ctx.remote exposes no Connection lifecycle control. A consumer whose responsibility includes recovery reads ctx.connection.state and calls ctx.connection.reconnect() directly; ordinary Remote consumers stay on generated namespaces and $stream().

Generated declaration merges provide the TypeScript API through the shared TypertClientRemote contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.

Model Experience

None, as the package dispatches application calls and registers no prompt, tool, or session event.

KV Cache effect

No direct effect; invoked business Services own any model-visible result.

Known Limitations and Deferred Work

  • The Connection adapter answers gateway/internal with empty details for dispatch failures and unclassified exceptions; a RemoteError thrown by an owner or by Gateway itself crosses the wire with its own code, message, and details. Its cause chain and the TypertGatewayError subclass identity survive only for same-process callers.
  • SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
  • Every Client-supplied field requires a strict generated codec when its contribution mounts. SRC markers have no Client type projection and are not normal Client contribution inputs.
  • $stream() supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal $events stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay.
  • Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same agent/session key.
  • Forwarded events reach $on without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime.
  • websocketHeartbeatIntervalMs is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it.
  • Uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with gateway/uplink-overflow, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request.

Dev Note

Working context for maintainers — click to expand

None.

Runtime invariant: No companion is published. Host calls re-read authoritative Cordis and Typert state, while Client methods, descriptors, and $on subscriptions mutate in one owned effect.

The ./stream-protocol export supplies the shared Remote stream framing and parser to native Desktop callers. These callers use the same authenticated WebSocket endpoint as the browser client.