Skip to content

[sdk-client S3] feat(sdk): expose the typed oRPC client-contribution seam #1349

Description

@rickylabs

Important

0.0.7 normative scope amendment — 2026-08-13. RFC 0001 Stage 2 supersedes conflicting rows
below. This issue ships the public contribution descriptors/tuple algebra and defaulted client/query
context generics, plus the stable-v1 implementation under the private
src/internal/client-contributions/ adapter. It must cover key algebra, reconnect preparation,
desktop rejection, cache modes, conflicts, and local failure taxonomy. It must not publicly
export createHttpClientLink, ClientLinkPort, ClientLinkCallOptions, or any internal
adapter port; must not accept upstream interceptor/plugin callback arrays; and must keep port
and timeout accepted/deprecated rather than remove them. #451 remains the sole future custom-link
owner and #1351 owns transport consolidation/no-op option migration.

Summary

CreateServiceClientOptions is a closed nine-field record and ServiceClientContext is a closed
interface, so no consumer can add a header, an interceptor, a link plugin, a custom fetch, or a
typed per-call context field. The link factory that would let them work around it is
package-private, even though the ports module's own doc comment advertises "the transport seam". On
the server side RPCHandlerConfig.plugins exists but the builder never populates it, and the plugin
type it accepts hands unknown to plugin authors. The result is that the only supported way to send
one extra header is to fork ~90 lines of framework internals per app — and silently lose NetScript's
client span in the process.

Evidence

  • Corpus: research/repo-audit/services-sdk.md §2.1–§2.4, §3.1–§3.3, §3.6, gap register
    S4/S11/S12/S13/S21; research/external/orpc.md §4 (G2, G3, G6, G7), §5;
    research/repo-audit/auth.md §0 proof 1–2, §2.
  • Source at baseline fac9e339042c (re-verified for this draft):
    • packages/sdk/src/ports/service-client.ts:203-222 — nine fields:
      contract, serviceName, routerName, protocol, apiPath, apiVersion, port, timeout, propagateTraceContext. No headers / fetch / interceptors / plugins / link / context
      parameter.
    • packages/sdk/src/ports/service-client.ts:129-155ServiceClientContext is a concrete
      interface (signal, cache, retry knobs, traceHeaders); :160-171 hard-codes it into
      ServiceClientMethod.
    • packages/sdk/src/client/service-client.ts:41-49port and timeout are never destructured;
      packages/sdk/src/presets/define-services.ts:106-116 forwards both into that ignoring function,
      so the dead options look live at L2 and L3.
    • packages/sdk/src/client/http-client-link.ts:82 (method: literal), :82-101 (headers
      literal: Content-Type + optional trace), :102-126 (plugins literal:
      ClientRetryPlugin{retry:0} + DedupeRequestsPlugin with frozen filter/groups), :127+
      (hard-coded fetch carrying the CLIENT span).
    • packages/sdk/src/client/mod.ts:15-36 — exports only createServiceClient, isDefinedError,
      safe, and types. createHttpClientLink is private.
    • packages/sdk/src/ports/mod.ts:7 — "…discovery metadata, and the transport seam" — while
      packages/sdk/src/ports/client-link-factory.ts:18-25 (ClientLinkPort,
      ClientLinkCallOptions) is not exported from that module.
    • packages/service/src/primitives/handlers.ts:41-58RPCHandlerConfig declares plugins,
      tracing, errorHandling, deduplication, warnOnlyCodes, logging, debug;
      packages/service/src/builder/service-rpc.ts:57 calls
      createRPCHandler(options?.rpcRouter ?? router, { serviceName, debug }) — nothing else is ever
      passed.
    • packages/service/src/types.ts:216-225ServiceHandlerPlugin.init?(options: unknown, router: unknown); packages/logger/orpc-plugin.ts:11-42 redeclares shim option types because of it.
    • packages/plugin/src/config/domain/plugin-contributions.ts:12-39 — no client-side contribution
      group.
  • Upstream (pinned 1.14.6, surface identical to 1.14.15 per research/external/orpc.md §0):
    StandardLinkOptions (interceptors, clientInterceptors, plugins),
    LinkFetchClientOptions (fetch, adapterInterceptors, plugins),
    StandardRPCLinkCodecOptions (headers, method, fallbackMethod, maxUrlLength,
    customJsonSerializers), ClientContext as a free type parameter.

Current surface

One constructor with no seams. createServiceClient builds rpcPath, calls the private
createHttpClientLink, and wraps the result in createORPCClient
(packages/sdk/src/client/service-client.ts:41-66). Consumers get exactly what that function
decided: two frozen client plugins, one header set, one fetch. @netscript/sdk/desktop proves the
link is swappable in principle (packages/sdk/src/desktop/application/desktop-rpc-client.ts:18-20
builds a MessagePort RPCLink with customJsonSerializers) and equally closed in practice. On the
server, withRPC() (packages/service/src/builder/service-builder.ts:91-104) and
DefineServiceOptions (packages/service/src/presets/define-service.ts:112-143) accept no
plugins, so a plugin that wants a server oRPC plugin must drop to L1 and hand-mount into Hono.

Target contract

Per RFC-A §3.1–§3.5, §3.9, §3.10:

  1. Client construction opens. CreateServiceClientOptions<TContract, TWith> gains an optional,
    ordered with?: TWith contribution chain and a mutually exclusive link?: ClientLinkPort<…>
    escape hatch. Omitting both yields byte-identical behaviour and types to today.
  2. Context becomes a parameter. ServiceClient<TContract, TContext> and
    ServiceClientMethod<TInput, TOutput, TError, TContext>; BaseServiceClientContext is today's
    shape; ServiceClientContext survives one minor as a @deprecated alias.
  3. The transport seam is exported. createHttpClientLink from @netscript/sdk/client;
    ClientLinkPort / ClientLinkCallOptions from @netscript/sdk/ports — closing the
    doc-vs-export contradiction at packages/sdk/src/ports/mod.ts:7.
  4. Framework defaults become contributions. Retry, dedupe, and the CLIENT span are composed
    through the same public path, so there is no private fast lane.
  5. Dead options die. port and timeout are removed from CreateServiceClientOptions and
    DefineServiceConfig; timeout is re-expressed as a contribution over AbortSignal.timeout.
  6. Server reachability + typing. withRPC() and DefineServiceOptions accept plugins and
    warnOnlyCodes and pass them to createRPCHandler; ServiceHandlerPlugin is typed to upstream's
    init?(options: StandardHandlerOptions<T>, router: Router<any, T>); the never-read
    deduplication option is either wired or removed.
  7. Env boundary. Contribution environment is checked at construction; server-only in a
    browser build throws.

Acceptance

  • createServiceClient accepts an ordered tuple of version-1 contribution descriptors whose
    public powers are limited to typed context projection, disjoint header keys/values, and declared
    response-cache behavior.
  • Omitting the tuple preserves today's wire behavior and existing call sites compile unchanged.
  • Client/query context generics default compatibly and compose to the intersection of declared
    contribution contexts.
  • The stable-v1 adapter lives under packages/sdk/src/internal/client-contributions/; neither
    createHttpClientLink nor ClientLinkPort/ClientLinkCallOptions is publicly exported.
  • Contributions cannot supply or observe fetch, link plugins, interceptor arrays, retry, dedupe,
    tracing, or the resolved HTTP method; those remain transport-owned.
  • port and timeout remain accepted and deprecated. Their migration/no-op disposition belongs
    to [sdk-client S4] refactor(sdk): centralize HTTP method and GET-cache policy #1351.
  • Construction rejects duplicate names/header ownership, unsupported contract versions, more than
    16 contributions, invalid dependency ordering, and desktop-incompatible contributions with
    deterministic errors naming the conflicting descriptors.
  • Reconnect preparation and cache modes are covered by positive and red-first negative tests;
    removing a descriptor removes its context/header/cache effect.
  • Server handler/plugin forwarding, handler option typing, and RPC deduplication are out of this
    client-seam leaf unless independently required by the RFC's private adapter.
  • deno task check, deno task test, and deno task publish:dry-run pass with isolated
    declarations intact.

Boundaries

Docs/consumer proof

docs/site/services-sdk/sdk.md and docs/site/reference/sdk/index.md gain one worked contribution
example that compiles under the docs gate; packages/sdk/README.md's export table lists the newly
exported transport seam; and the "escape hatch = fork the link" paragraph is deleted rather than
softened. Consumer proof is that an app can add one header without importing anything from
@netscript/telemetry and without losing its client span — assert the span is still emitted in the
same test that asserts the header.

Provenance

Seed run plan-fable5-remediation-roadmap--seed, PR #1347, 2026-08-08. Sourced from
research/repo-audit/services-sdk.md (S4, S11, S12, S13, S21), research/external/orpc.md (G2, G3,
G6, G7) and research/repo-audit/auth.md (G1); all cited lines re-verified against worktree baseline
fac9e339042c. No GitHub mutation was performed.


Filed from planning seed PR #1347 · source Draft-ID T1-02 · live issue #1349.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions