Skip to content

feat(rpc): adopt oRPC v2 for service contracts#114

Draft
AmanVarshney01 wants to merge 1 commit into
mainfrom
aman/orpc-v2-rpc
Draft

feat(rpc): adopt oRPC v2 for service contracts#114
AmanVarshney01 wants to merge 1 commit into
mainfrom
aman/orpc-v2-rpc

Conversation

@AmanVarshney01

Copy link
Copy Markdown
Member

What this explores

This concept PR replaces Composer's hand-written RPC protocol layer with native oRPC v2 contracts, routers, and clients.

Composer still owns the differentiated parts: application topology, dependency hydration, per-edge service-key authorization, mount policy, and deployment. oRPC owns the generic RPC machinery: schema execution, transport, routing, structured errors, middleware, metadata, cancellation, and codecs.

Point-to-point example

Define one shared contract with normal oRPC syntax:

import { contract, oc } from '@prisma/composer/rpc'
import { type } from 'arktype'

export const calculatorContract = contract({
  double: oc
    .input(type({ value: 'number' }))
    .output(type({ result: 'number' })),
})

Implement it in the private provider:

import { implement, serve } from '@prisma/composer/rpc'

const rpc = implement(calculatorContract.router)

export default serve(calculatorService, {
  rpc: rpc.router({
    double: rpc.double.handler(({ input }) => ({
      result: input.value * 2,
    })),
  }),
})

Declare the service-to-service dependency:

export default compute({
  name: 'app',
  deps: {
    calculator: rpc(calculatorContract),
  },
  build: node({
    module: import.meta.url,
    entry: '../dist/app/index.mjs',
  }),
})

Use the injected typed client from any Fetch-capable framework, including Hono:

const { calculator } = appService.load()

app.get('/double/:value', async (c) => {
  const { result } = await calculator.double({
    value: Number(c.req.param('value')),
  })

  return c.json({ result })
})

The complete call is deliberately boring:

GET /double/21
  -> Hono app
  -> calculator.double({ value: 21 })
  -> { result: 42 }

What oRPC brings

  • native contract-first clients and Standard Schema transforms
  • nested routers without flattening method names
  • structured RPC errors with stable codes
  • middleware, metadata, cancellation, and request codecs
  • Fetch-native server/client transport and configurable body limits
  • a clean path to opt-in oRPC OpenAPI tooling without Composer inventing another protocol

The PR also migrates the existing Composer examples and adds a small end-to-end Hono + private Compute service example under examples/hono-rpc.

oRPC v2 is currently beta, so the runtime packages are pinned to the exact same version and Composer's wrapper stays intentionally small.

@wmadden-electric

Copy link
Copy Markdown
Contributor

Thanks for this PR — it's a serious piece of work, and evaluating it properly
forced us to firm up design decisions that were vaguer than they should have
been. The decision is not now: we're keeping the hand-written RPC layer
for the moment. But we do want oRPC in the picture later, in a different
shape than this PR, and I want to explain the reasoning properly rather than
just close it.

What we checked, and what held up

We went through your changes carefully, expecting to find problems, and
mostly didn't:

  • The type checking still works. The thing we care most about is that
    TypeScript rejects a bad wiring between services: a provider can offer
    more methods or richer results than a consumer asks for, but a provider
    that requires an input field the consumer never sends must not compile.
    We ran your migrated type tests against the branch and all of those cases
    behave correctly with oRPC's inferred client types. All the runtime tests
    pass too.
  • Interoperability doesn't require the library. A requirement of ours:
    two services must be able to talk as long as they agree on the contract
    and the wire protocol — neither side gets to force the other to run a
    particular npm package. oRPC clears this bar: the protocol doc
    (https://orpc.dev/docs/advanced/rpc-protocol) is enough to build a
    compatible client or server from scratch. One note: your ADR says
    hand-authored requests are not a supported client contract — we'd want
    the opposite stance, since that independence is the point.
  • You drew the responsibilities in the right place. oRPC handling
    validation, serialization, and routing while our code handles which
    services are connected, the per-connection auth key, and which procedures
    are actually published — that's the correct split, and details like
    running auth before the body is parsed are done well.

So this isn't "the code has problems". It doesn't, beyond a broken example
build in CI.

Why we're saying not now

You saw a reinvented wheel, and that's a fair reading. Here's the deliberate
part that isn't visible from the code: service-to-service calls in this
framework are trivial on purpose — a consumer calls
auth.verify({ token }) and gets a typed result, and that's the whole
story. Our RPC layer serves that with about four concepts and ten exports in
~420 lines.

Making oRPC the RPC layer means every user authors contracts and servers
with oRPC's API — which comes with its whole world: middleware, contexts,
plugins, links, a few hundred exported names. Your ADR argues that hiding
oRPC behind a small wrapper throws away the main reason to adopt an RPC
ecosystem. This is where we disagree about what the layer is for. We didn't
adopt RPC to give users an RPC framework. We adopted it for one narrow job:
strictly-checked connections between services, and a typed client generated
from a strict contract — it exists to support the topology. These are
internal service-to-service edges, not your application's API, and the
features that make oRPC attractive as an application API layer are exactly
the ones an internal edge doesn't need.

Worth being explicit: none of this limits what you build your app with.
Composer never touches your application code — if you want native oRPC for
the API your app serves to the internet, use it, today, as-is. This decision
is only about what the framework's own inter-service wiring imposes on every
user.

Where this work fits later

The direction we've landed on — partly because this PR forced the question:

  • The small built-in contract stays the default, and it's what consumers
    declare dependencies with: depend on the minimum you need.
  • oRPC becomes an optional richer way to build a provider. A module author
    who wants typed errors, middleware, or streaming builds their service with
    full native oRPC.
  • The connecting piece: a service built with rich oRPC should still satisfy
    a consumer who only declared a plain minimal contract. We've verified the
    type-level half of this already works — oRPC's inferred client type is
    assignable to our plain function signatures, with the variance rules
    intact. The runtime half needs design work we haven't done yet (checking
    contract compatibility across the two styles, and picking the right
    client transport per connection).

That's a bigger change than this PR, and it would be unfair to ask you to
rebuild toward a design that doesn't exist yet. So we're parking adoption
until it does. When it does, a lot of this PR carries over almost unchanged
— the wrapper, the serve() port checks, the auth interceptor, the tests.

What we're taking now

Comparing the two implementations exposed three gaps in our current code
that don't need oRPC to fix, and we're stealing them from your PR:

  1. a request body size limit,
  2. not leaking handler exception messages to callers,
  3. removing a redundant second validation of responses.

Thanks again — this genuinely moved the design forward even though it isn't
landing as-is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants