diff --git a/docs/design/10-domains/connection-contracts.md b/docs/design/10-domains/connection-contracts.md index 9203f579..d6ef484a 100644 --- a/docs/design/10-domains/connection-contracts.md +++ b/docs/design/10-domains/connection-contracts.md @@ -21,8 +21,11 @@ both sides. ```ts // auth/contract.ts — the shared Contract value. Its identity is the Load-time key. +import { contract, oc } from "@prisma/composer/rpc" +import { type } from "arktype" + export const authContract = contract({ - verify: rpc({ input: type({ token: "string" }), output: type({ ok: "boolean" }) }), + verify: oc.input(type({ token: "string" })).output(type({ ok: "boolean" })), }) // auth/service.ts — the provider DECLARES what it exposes. `expose` is a record of @@ -32,13 +35,17 @@ export default compute({ expose: { rpc: authContract }, }) -// auth/server.ts — the provider IMPLEMENTS it. serve() reads the service's `expose` -// and forces an exhaustive, correctly-typed handler map; it generates the server and -// calls load() for the deps, passing them to each handler. +// auth/server.ts — the provider IMPLEMENTS it with native oRPC. implement() forces +// an exhaustive, correctly-typed router; serve() verifies and mounts that router. +import { implement, serve } from "@prisma/composer/rpc" +import { authContract } from "./contract" import service from "./service" -export default serve(service, { - rpc: { verify: async ({ token }, { db }) => ({ ok: await check(db, token) }) }, +const { db } = service.load() +const rpc = implement(authContract.router) +const router = rpc.router({ + verify: rpc.verify.handler(async ({ input }) => ({ ok: await check(db, input.token) })), }) +export default serve(service, { rpc: router }) // storefront/service.ts — the consumer REQUIRES the contract: rpc(contract), not http(). export default compute({ @@ -61,7 +68,8 @@ module("storefront-auth", (h) => { Each piece carries the type that makes the wiring check itself: - **`rpc(contract)`** on the consumer's dependency carries the *required* contract - and hydrates to `Client` (`{ verify(input): Promise }`). RPC is a + and hydrates to oRPC's inferred native client (`{ verify(input): + Promise }`). RPC is a protocol the framework owns, so a dependency's binding IS a derived client — the most-derived thing the contract can construct; a resource kind (postgres) instead binds to its typed config and the app builds its own client @@ -106,9 +114,9 @@ and the number of outputs are all open without the core changing. ## A Connection is a port The Contract is the port; the code on either side is an adapter. The provider's -server and the consumer's client are both generated from the contract by the same -target pack, so the wire format between them is private to the pack — not a -framework concern. +router and the consumer's client are both inferred from the same native oRPC +contract. Composer layers topology and binding authorization around oRPC's wire +runtime, so the core framework never inspects protocol details. Because the consumer holds a client generated from the contract, **the binding does not have to be a network hop.** Which adapter sits behind the client is a wiring @@ -185,35 +193,29 @@ declare function provision>>( ``` The core never inspects `Cmp`. Correctness comes from the **kind**, which builds -`Cmp` so that plain assignability means the right thing. For RPC, `Cmp` is a -**concrete function map**: `rpc()` returns a concrete `(input: I) => Promise`, so -`contract()`'s `Cmp` is `{ verify: (input) => Promise, … }`: +`Cmp` so that plain assignability means the right thing. For RPC, Composer uses +oRPC's `RouterContractClient` for the exact native router retained by +`contract()`: ```ts -declare function rpc(m: { input: Schema; output: Schema }): (input: I) => Promise -declare function contract Promise>>(fns: Fns): Contract<"rpc", Fns> -type Client = C extends Contract ? Cmp : never +declare function contract(router: R): + Contract<"rpc", RouterContractClient> & { readonly router: R } + +type Client = C extends Contract<"rpc", infer Cmp> ? Cmp : never ``` -Because `Cmp` is a concrete function map, TypeScript applies real function variance -to `provided extends required` — **contravariant input, covariant output**, plus -width. A provider may expose extra methods and return richer outputs; a provider -that requires an input field the consumer never sends is rejected; a wrong-kind -provider is rejected by the brand. - -The comparison type **must** be a concrete function map, not a mapped type over the -schema. If the contract were parameterised by its schema map `M` and the client -derived as a mapped type over `M`, comparing two instantiations would relate the -`M`s covariantly — silently dropping input-contravariance and accepting a provider -that demands an input the consumer never sends. Materialising `Cmp` as concrete -functions in the kind's builder is what makes plain assignability sound. (See -`@prisma/composer/rpc`'s `contract-satisfaction.test-d.ts` for the full accept/reject -matrix, typechecked in CI.) +The inferred client is the comparison surface. TypeScript applies method width and +function variance to `provided extends required`: a provider may expose extra +methods and return richer outputs, while a provider that requires an input the +consumer never sends is rejected. A wrong-kind provider is rejected by the brand. +`@prisma/composer/rpc`'s `contract-satisfaction.test-d.ts` keeps the complete +accept/reject matrix under CI because subtle generic refactors can otherwise erase +input contravariance. Schemas are **Standard Schema** (arktype is the canonical authoring library; any -Standard-Schema validator works, so an Effect provider and a plain consumer -interoperate over one contract). They carry the runtime validators and drive the -published spec; the *type* the core compares is the function map. +Standard-Schema validator works). Native oRPC executes those schemas and retains +their metadata, error maps, and nested router structure; the *type* the core +compares is the inferred client. ### Growing the runtime check @@ -231,29 +233,40 @@ engine as the Data Contract migration check — "a newly deployed provider must satisfy every existing consumer." The in-build Module is fully covered by the three layers above. -## Implementing — why the handler cannot skip the contract +## Implementing — why the router cannot skip the contract -`serve(service, handlers)` derives the required handler map from the service's -`expose`, so an incomplete or mistyped implementation does not compile: +Native oRPC's `implement(contract.router)` derives every procedure implementer +from the contract, so an incomplete or mistyped router does not compile. +Composer's `serve(service, routers)` separately derives one contracted router +slot for every RPC port in the service's `expose`: ```ts -function serve(service: S, handlers: Handlers>): Server +const rpc = implement(authContract.router) +const router = rpc.router({ + verify: rpc.verify.handler(({ input }) => ({ ok: input.token.length > 0 })), +}) -type Handlers = { - [Port in keyof E]: { [M in keyof E[Port]]: (input: In, deps: Deps) => Promise> } +serve(authService, { rpc: router }) + +type Routers = { + [Port in RpcPorts]: ContractedRouter> } ``` +At runtime, `serve()` also requires the router's hidden native contract to be the +exact router retained by the exposed Composer contract. Its oRPC matcher filters +out any structurally-added procedure that was not declared in that contract, and +it rejects duplicate full procedure paths across exposed ports. + The contract lives once, on the definition; the implementation is *handed* that definition and cannot compile unless it satisfies it. The import points definition-ward only (`server.ts` → `service.ts`, never the reverse), so bundling `service.ts` into the runtime wrapper never pulls in the entry's app code — the acyclic bundle shape from core-model.md is preserved. -The type system forces `serve(...)` to be complete and correct, but cannot force the -entry to *call* `serve` at all. `serve` is strictly the easier path (fill handlers, -no transport), and a deploy-time probe (call each method against the started server, -check the shape) can close that gap at runtime if a hard guarantee is ever wanted. +The type system forces `implement(...)` and `serve(...)` to be complete and +correct, but cannot force the entry to call them at all. A deploy-time probe can +close that final gap if a hard runtime guarantee is ever wanted. ## Ownership @@ -280,10 +293,16 @@ contract's identity-based `satisfies()` is enough today. Comparing by value identity is enough while both sides import one contract, and it is the simplest thing that is correct. Structural comparison is additive (above) and only *required* for the distributed case, so the RPC contract defers it. -- **Errors deferred** from the first contract shape (`{ input, output }` only). - Adding an `error` schema later is backward-compatible (an optional field). -- **Wire format private to the target pack**, which owns both adapters — rather than - a framework-fixed wire format. +- **Native oRPC contracts rather than a Composer procedure DSL.** This keeps + typed errors, middleware, metadata, nesting, and Standard Schema behavior on + the upstream ecosystem surface instead of recreating them in Composer. +- **oRPC's wire format remains private to Composer's RPC kind.** Application + topology depends on a Contract, not on HTTP codec details. Provider and + consumer artifacts therefore need compatible Composer/oRPC versions. +- **OpenAPI is compatible but not bundled.** `contract.router` is the native + router an opt-in oRPC OpenAPI adapter can consume. Composer's private + service-to-service `serve()` remains RPC-only until a public HTTP/OpenAPI + surface is designed deliberately. - **PDL as a later authoring surface.** A Contract is a value today (Standard Schema in TypeScript). Later it can be authored in Prisma Definition Language and compiled to the same value — the Data Contract is the natural first PDL output, the RPC diff --git a/docs/design/10-domains/core-model.md b/docs/design/10-domains/core-model.md index f563d404..93740cf4 100644 --- a/docs/design/10-domains/core-model.md +++ b/docs/design/10-domains/core-model.md @@ -62,7 +62,7 @@ The boundary is decided; only the carve is deferred. | `@prisma/composer` | node factories (`service`, `resource`, `dependency`, `module`), `Load`, `configOf`, `hydrate`, `BuildAdapter` type, model types (incl. `Config`) | nothing | | `@prisma/composer/deploy` | `lower()`, `lowering()`, `Target` types, `Bundle`/`AssembleInput` (the assembler seam's contract, defined once here) | `alchemy`, `effect` | | `@prisma/composer-prisma-cloud` | `compute()` (declares a service; carries `run`/`load`), `postgres()` (`{ name }` identity or `{ client }` dependency, by argument shape) + `postgresContract`, `http()` | `@prisma/composer` only | -| `@prisma/composer/rpc` | the RPC Contract kind — `contract()`, `rpc()`, `serve()`, the typed client binding (see [`connection-contracts.md`](connection-contracts.md)) | `@prisma/composer` + a Standard Schema validator | +| `@prisma/composer/rpc` | the RPC Contract kind — native oRPC `oc` contracts and `implement()` routers plus Composer `contract()`, `rpc()`, `serve()`, and the typed client binding (see [`connection-contracts.md`](connection-contracts.md)) | `@prisma/composer` + oRPC v2 + a Standard Schema validator | | `@prisma/composer-prisma-cloud/cron` | cron as a driver (see [ADR-0020](../90-decisions/ADR-0020-scheduled-work-is-a-driver-not-a-resource.md)) — `defineSchedule`, `serveSchedule`, `cronScheduler`, `cron()`, `triggerContract` | `@prisma/composer` + `app-node` + `app-rpc` | | `@prisma/composer-prisma-cloud/storage` | S3-compatible object storage as a module (S3 wire protocol on Compute + Postgres `bytea`; see [`README`](../../../packages/1-prisma-cloud/2-shared-modules/storage/README.md)) — `storage()`, `s3()` + `s3Contract`/`S3Config`, `storageService`; `/storage/testing` adds the `createPgStore` + `startStorageServer` local stand-in | `@prisma/composer` + `app-node` + `@prisma/composer-prisma-cloud` | | `@prisma/composer-prisma-cloud/target` | `prismaCloud()` | `@internal/lowering`, `alchemy`, `effect` | diff --git a/docs/design/90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md b/docs/design/90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md index 304d846a..07c638d2 100644 --- a/docs/design/90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md +++ b/docs/design/90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md @@ -56,7 +56,7 @@ the caller's Standard Schema on the message and lets the transport handle the wire; the schema is the caller's, the serialization is the framework's: ```ts -rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }) +oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })) ``` Params take the same shape. The schema is the caller's — it types the value and diff --git a/docs/design/90-decisions/ADR-0033-composer-rpc-adopts-native-orpc-v2-contracts-and-runtime.md b/docs/design/90-decisions/ADR-0033-composer-rpc-adopts-native-orpc-v2-contracts-and-runtime.md new file mode 100644 index 00000000..51c885b8 --- /dev/null +++ b/docs/design/90-decisions/ADR-0033-composer-rpc-adopts-native-orpc-v2-contracts-and-runtime.md @@ -0,0 +1,118 @@ +# ADR-0033: Composer RPC adopts native oRPC v2 contracts and runtime + +## Decision + +Composer RPC uses oRPC v2 as its public contract-first programming model and +its wire runtime. `@prisma/composer/rpc` re-exports `oc` for contract authoring +and `implement()` for server routers. Composer's small `contract()` wrapper +retains the exact native router while adding the identity and inferred client +shape required by the application topology. + +```ts +import { contract, implement, oc, serve } from '@prisma/composer/rpc'; +import { type } from 'arktype'; + +export const ordersContract = contract({ + place: oc.input(placeOrder).output(placedOrder), + admin: { + cancel: oc.input(type({ orderId: 'string' })).output(type({ cancelled: 'boolean' })), + }, +}); + +const rpc = implement(ordersContract.router); +const router = rpc.router({ + place: rpc.place.handler(({ input }) => place(input)), + admin: rpc.admin.router({ + cancel: rpc.admin.cancel.handler(({ input }) => cancel(input.orderId)), + }), +}); + +export default serve(service, { rpc: router }); +``` + +The consumer still declares topology with `rpc(ordersContract)`. Its +`service.load()` result is oRPC's inferred native client, including the same +nested shape (`orders.admin.cancel(...)`). + +oRPC owns procedure schemas, nesting, middleware, metadata, typed errors, +request/response codecs, Fetch routing, Standard Schema execution, and +cancellation. Composer owns application topology, nominal contract matching, +dependency hydration, per-edge service-key authorization, mount policy, +cross-port path collision checks, and request limits. + +The oRPC packages are exact-version runtime dependencies while v2 is beta. +Applications import the supported API from `@prisma/composer/rpc`, avoiding +multiple incompatible oRPC versions in one graph. + +## Reasoning + +The previous Composer-specific `rpc({ input, output })` procedure DSL hid oRPC +behind a thin adapter. That kept a small surface, but it also discarded the +main reason to adopt an RPC ecosystem: users could not naturally use native +routers, nesting, middleware, contract metadata, typed error declarations, or +ecosystem tooling. Composer would eventually have had to mirror each feature +with another abstraction. + +Native oRPC fits Composer's boundary directly. A Composer service exposes a +contract router as a typed output port; a consumer requires that same router +through `rpc(contract)`; and the server implements it with oRPC. Composer adds +only the deployment facts oRPC cannot know: which services are connected, +which URL and capability key hydrate that edge, and which router belongs to +which exposed topology port. + +`serve()` accepts one native contracted router per exposed RPC port. It checks +that each implementation was created from that port's exact `contract.router`, +filters dispatch to procedures declared by the contract, and rejects duplicate +full procedure paths across ports. An implementation object therefore cannot +publish a procedure that is absent from the topology contract. + +Authorization runs after a route is matched but before its body is decoded. +An unwired caller receives `401` without invoking validation or application +code. Wrong methods, schema failures, expected oRPC errors, unexpected errors, +and body limits then use oRPC's normal production behavior. + +## Consequences + +- Contract and server authoring use native oRPC syntax. This intentionally + replaces Composer's earlier procedure and handler-map DSL while the project + is still pre-adoption. +- Native nested routers are preserved. The default wire path is + `/rpc/` and the client has the same nested object shape. +- Standard Schema transforms execute once through oRPC. Handlers receive the + input schema's output type, return the output schema's input type, and clients + receive the transformed output. +- Expected remote failures use `RpcError` (an `ORPCError` re-export) and retain + their code and intentional message. Unexpected exceptions are masked as + `INTERNAL_SERVER_ERROR`. +- Client calls can carry an `AbortSignal`. Encoded request bodies are limited + to one mebibyte by default with an explicit `serve()` override. +- `serve()` returns a Web Fetch handler, so Bun, Hono, Next.js, and other + Fetch-capable frameworks need no Composer-specific framework adapter. +- `contract.router` stays a native oRPC router and can be consumed by opt-in + oRPC ecosystem tooling. Composer does not bundle an OpenAPI/REST handler yet; + that public ingress surface remains a separate product decision. +- Provider and consumer artifacts must use compatible Composer/oRPC versions; + hand-authored requests are not a supported client contract. + +## Alternatives considered + +- **Keep the Composer procedure DSL over oRPC.** Rejected because it hides + oRPC's useful authoring model and makes Composer reproduce upstream features. +- **Continue the hand-written JSON protocol.** Rejected because Composer would + own codec correctness, cancellation, safe errors, transforms, limits, and + compatibility without creating differentiated value. +- **Let oRPC own topology or service authorization.** Rejected because those + are deployment-graph facts. They belong to Composer and its target, not an + application RPC library. +- **Bundle OpenAPI immediately.** Deferred. Native contracts remove the + architectural blocker, but public HTTP authentication, route semantics, + documentation exposure, and lifecycle need an explicit design. + +## Related + +- [ADR-0015](ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md) — + Composer constructs protocol clients while resolving dependency bindings. +- [ADR-0030](ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md) — + the per-edge service key Composer continues to attach and validate. +- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — application + frameworks and their runnable builds remain user-owned. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 10a543b2..19fde996 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -54,3 +54,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0030](ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md) — Every RPC binding carries a distinct, framework-minted **service key**: the client sends it, `serve()` rejects a caller without one (401). Auto-provisioned per edge at deploy, carried on the binding's own `COMPOSER_*` env-var rail; its value is minted and kept in the workspace deploy state (a capability token, deliberately not an ADR-0029 name-only secret). *(Proposed)* - [ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) — A framework-minted param value is an opaque, branded **provisioning need** (not a named facet on the param): core forwards it and resolves its brand against the deploy target's `provisions` registry, failing loudly on a miss. The provisioner owns all mint/size/stability/rotation policy; core's surface stays one field. Resolves against the consumer's extension; cross-extension edges fail closed. *(Proposed)* - [ADR-0032](ADR-0032-params-bind-at-provision-env-sourcing-is-a-target-source.md) — A param can be bound at `provision()` — a schema-validated literal (beats `default`) or a target-owned source (`envParam('NAME')`, mirroring ADR-0029's need/source split): a pointer row on the wire, boot double-lookup, the raw string handed to the param's own schema, read through `config()` unredacted; preflight covers the names like secrets. +- [ADR-0033](ADR-0033-composer-rpc-adopts-native-orpc-v2-contracts-and-runtime.md) — Composer adopts native oRPC v2 contracts, routers, and runtime while retaining topology, dependency hydration, exact contract identity, per-edge authorization, mount policy, and request limits. diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md index 5d29c310..55a320f9 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -32,30 +32,37 @@ production, a stage, a test — just a different set of injected values. ## Contracts -A **contract** is a service's API written as schemas, and it types both ends -of the edge: the producer's handlers and the consumer's client. Define it -once, in the package of the service that owns it: +A **contract** is a service's API written as a native oRPC contract router. It +types both ends of the edge: the producer's handlers and the consumer's +client. Composer's `contract()` keeps that router intact and adds the topology +identity used while wiring services. Define it once, in the package of the +service that owns it: ```ts -import { contract, rpc } from '@prisma/composer/rpc'; +import { contract, oc } from '@prisma/composer/rpc'; import { type } from 'arktype'; export const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); ``` -On the producer, `serve()` turns the service's `expose` into a fetch handler. -The handler map must cover every method — a missing or wrong-shaped handler -doesn't compile. Each handler receives the validated input (and the service's -own loaded deps as a second argument): +On the producer, native oRPC `implement()` makes the implementation exhaustive +and infers the validated handler input and output. `serve()` verifies that the +router came from the exact contract exposed on that port, then returns a Web +Fetch handler: ```ts -const handler = serve(service, { - rpc: { - verify: async ({ token }) => ({ ok: await check(token) }), - }, +import { implement, serve } from '@prisma/composer/rpc'; + +const rpc = implement(authContract.router); +const router = rpc.router({ + verify: rpc.verify.handler(async ({ input }) => ({ + ok: await check(input.token), + })), }); + +const handler = serve(service, { rpc: router }); ``` On the consumer, declare `deps: { auth: rpc(authContract) }` and @@ -66,6 +73,59 @@ the boundary, against the same schemas. The only contract kind today is RPC over HTTP — no gRPC, WebSockets, or streaming contracts yet. +### Production RPC behavior + +Composer deliberately exposes oRPC v2's contract-first syntax through +`@prisma/composer/rpc`: `oc` for contracts and `implement()` for routers. +oRPC owns procedures, nested routing, middleware, Standard Schema execution, +the wire codec, structured errors, and cancellation. Composer owns the +application graph, contract identity, dependency hydration, per-edge service +keys, mount policy, and request limits. Import from Composer's entry point so +every package uses the exact oRPC version Composer supports. + +Schema transforms run exactly once in native oRPC. A handler receives the +**output** of its input schema and returns the **input** of its output schema; +the provider validates/transforms that result and the generated client receives +the output schema's transformed value. + +Expected public failures are structured `RpcError`s: + +```ts +import { RpcError } from '@prisma/composer/rpc'; + +throw new RpcError('CONFLICT', { message: 'The order was already submitted' }); +``` + +The consumer receives a `RpcError` with the same stable `code` and intentional +message. An ordinary unexpected `Error` becomes `INTERNAL_SERVER_ERROR`; its +message and stack are not sent to the caller. + +Every client method accepts an optional cancellation signal: + +```ts +await auth.verify({ token }, { signal: request.signal }); +``` + +`serve()` accepts a third options argument. Request bodies are limited to one +mebibyte by default; set a smaller or larger positive byte limit explicitly +when the contract needs it: + +```ts +serve(service, { rpc: router }, { maxBodySize: 256 * 1024 }); +``` + +The default mount is `/rpc/`, including native nested routers. +A framework can call the returned Fetch handler directly, or mount it at an +explicit prefix with +`{ prefix: '/api/v1/rpc' }`; Composer never guesses or rewrites application +paths. Full procedure paths must be unique across all RPC ports exposed by one +service because those routers share the mount. + +[`examples/hono-rpc`](../../examples/hono-rpc/) is a complete framework +example: a public Hono gateway calls a second Compute service through a typed +Composer RPC dependency, with an integration test that boots both compiled +entries and drives the whole chain over real HTTP. + ### Calls are authenticated for you You don't do anything for this. There is no key in your code, your contract, diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 10200cb2..f1796635 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -100,11 +100,11 @@ valibot…); the examples use arktype: ```ts // src/quotes/contract.ts -import { contract, rpc } from '@prisma/composer/rpc'; +import { contract, oc } from '@prisma/composer/rpc'; import { type } from 'arktype'; export const quotesContract = contract({ - random: rpc({ input: type({}), output: type({ quote: 'string' }) }), + random: oc.input(type({})).output(type({ quote: 'string' })), }); ``` @@ -127,13 +127,15 @@ export default compute({ ``` Third, the **server** — the code your build turns into `dist/quotes/server.mjs` -and the platform boots. `serve()` generates the HTTP handler from the -contract; if you forget a handler or return the wrong shape, it doesn't -compile: +and the platform boots. `implement()` is oRPC's contract-first implementer: it +infers each handler from the contract and refuses missing handlers or wrong +outputs. `serve()` mounts that implemented router for the service's exposed +port: ```ts // src/quotes/server.ts -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; +import { quotesContract } from './contract.ts'; import service from './service.ts'; const { port } = service.config(); @@ -143,11 +145,14 @@ const QUOTES = [ 'Make it work, make it right, make it fast.', ]; -const handler = serve(service, { - rpc: { - random: async () => ({ quote: QUOTES[Math.floor(Math.random() * QUOTES.length)]! }), - }, +const rpc = implement(quotesContract.router); +const router = rpc.router({ + random: rpc.random.handler(() => ({ + quote: QUOTES[Math.floor(Math.random() * QUOTES.length)]!, + })), }); + +const handler = serve(service, { rpc: router }); export default handler; // Bind all interfaces — Compute routes external HTTP to the VM, so a diff --git a/examples/cron/src/worker/contract.ts b/examples/cron/src/worker/contract.ts index 033a7cbf..69a4f156 100644 --- a/examples/cron/src/worker/contract.ts +++ b/examples/cron/src/worker/contract.ts @@ -4,10 +4,10 @@ * contract.ts); the runner imports it to depend on the worker via * `rpc(workerContract)`. */ -import { contract, rpc } from '@prisma/composer/rpc'; +import { contract, oc } from '@prisma/composer/rpc'; import { type } from 'arktype'; export const workerContract = contract({ - tick: rpc({ input: type({}), output: type({ ok: 'boolean' }) }), - refreshMrr: rpc({ input: type({}), output: type({ ok: 'boolean' }) }), + tick: oc.input(type({})).output(type({ ok: 'boolean' })), + refreshMrr: oc.input(type({})).output(type({ ok: 'boolean' })), }); diff --git a/examples/cron/src/worker/server.ts b/examples/cron/src/worker/server.ts index ac4758f9..7cce34e2 100644 --- a/examples/cron/src/worker/server.ts +++ b/examples/cron/src/worker/server.ts @@ -3,7 +3,8 @@ // re-keyed the platform environment address-free, so service.load()/config() // below read it directly, with no address. -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; +import { workerContract } from './contract.ts'; import service from './service.ts'; const { port } = service.config(); @@ -11,12 +12,12 @@ const { port } = service.config(); // The trivial target the schedule fires: both jobs just prove they were // reached (the real work — an ingest job, an MRR refresh, whatever — is the // app's, not this framework's, concern). -const handler = serve(service, { - rpc: { - tick: async () => ({ ok: true }), - refreshMrr: async () => ({ ok: true }), - }, +const rpc = implement(workerContract.router); +const router = rpc.router({ + tick: rpc.tick.handler(() => ({ ok: true })), + refreshMrr: rpc.refreshMrr.handler(() => ({ ok: true })), }); +const handler = serve(service, { rpc: router }); export default handler; // Bind all interfaces — Compute routes external HTTP to the VM, so a diff --git a/examples/cron/testing/fake.ts b/examples/cron/testing/fake.ts index 5ff82390..a48ab7b7 100644 --- a/examples/cron/testing/fake.ts +++ b/examples/cron/testing/fake.ts @@ -1,7 +1,7 @@ /** * A fake worker for TESTING a module that depends on it — injected in place * of the real one so the runner's integration test needs no deployed worker. - * Serves the real `workerContract` (so its handler map is type-checked + * Implements the real `workerContract` (so its native router is type-checked * against the same contract the real worker exposes) and records which * methods were called, so the test can assert the cron pipeline actually * reached the target. Not a build entry, so tsdown never bundles it into the @@ -13,7 +13,7 @@ */ import node from '@prisma/composer/node'; -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; import { compute } from '@prisma/composer-prisma-cloud'; import { workerContract } from '../src/worker/contract.ts'; @@ -31,17 +31,17 @@ export interface FakeWorker { export function createFakeWorker(): FakeWorker { const calls: string[] = []; - const fetch = serve(fakeWorker, { - rpc: { - tick: async () => { - calls.push('tick'); - return { ok: true }; - }, - refreshMrr: async () => { - calls.push('refreshMrr'); - return { ok: true }; - }, - }, + const rpc = implement(workerContract.router); + const router = rpc.router({ + tick: rpc.tick.handler(() => { + calls.push('tick'); + return { ok: true }; + }), + refreshMrr: rpc.refreshMrr.handler(() => { + calls.push('refreshMrr'); + return { ok: true }; + }), }); + const fetch = serve(fakeWorker, { rpc: router }); return { fetch, calls }; } diff --git a/examples/hono-rpc/README.md b/examples/hono-rpc/README.md new file mode 100644 index 00000000..016389e1 --- /dev/null +++ b/examples/hono-rpc/README.md @@ -0,0 +1,80 @@ +# Simple Hono RPC example + +A public Hono app calls one private calculator service: + +```text +GET /double/21 -> Hono app -> calculator.double({ value: 21 }) -> { result: 42 } +``` + +The example is intentionally flat: + +```text +src/ +├── index.ts # public Hono app and RPC client +├── calculator.ts # private RPC provider +├── contract.ts # shared oRPC contract +├── app-service.ts # app deployment declaration +└── calculator-service.ts # calculator deployment declaration +``` + +There are two runtime files because the app and calculator are two separately +deployed Compute processes. Each deployment also has one tiny declaration file +because Compute boots a service through that file's default export. There are +no `gateway/` or `math/` folder hierarchies. + +## Shared contract + +```ts +export const calculatorContract = contract({ + double: oc.input(type({ value: 'number' })).output(type({ result: 'number' })), +}); +``` + +## Private provider + +```ts +const rpc = implement(calculatorContract.router); + +const handler = serve(calculatorService, { + rpc: rpc.router({ + double: rpc.double.handler(({ input }) => ({ + result: input.value * 2, + })), + }), +}); +``` + +## Hono app + +```ts +const { calculator } = appService.load(); + +app.get('/double/:value', async (context) => { + const { result } = await calculator.double({ + value: Number(context.req.param('value')), + }); + + return context.json({ result }); +}); +``` + +Composer supplies the calculator URL and a private service key from the +declared dependency edge. Hono does not need a Composer-specific integration. + +## Run the verification + +```sh +pnpm --filter @prisma/example-hono-rpc typecheck +pnpm --filter @prisma/example-hono-rpc build +pnpm --filter @prisma/example-hono-rpc test +``` + +## Deploy + +Set `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`, then run: + +```sh +pnpm --filter @prisma/example-hono-rpc deploy +``` + +Open the app Compute URL at `/double/21`. diff --git a/examples/hono-rpc/module.ts b/examples/hono-rpc/module.ts new file mode 100644 index 00000000..41cf5132 --- /dev/null +++ b/examples/hono-rpc/module.ts @@ -0,0 +1,9 @@ +import { module } from '@prisma/composer'; +import appService from './src/app-service.ts'; +import calculatorService from './src/calculator-service.ts'; + +/** A Hono app connected to a private typed RPC service. */ +export default module('hono-rpc-example', ({ provision }) => { + const calculator = provision(calculatorService); + provision(appService, { deps: { calculator: calculator.rpc } }); +}); diff --git a/examples/hono-rpc/package.json b/examples/hono-rpc/package.json new file mode 100644 index 00000000..8c5b59ef --- /dev/null +++ b/examples/hono-rpc/package.json @@ -0,0 +1,27 @@ +{ + "name": "@prisma/example-hono-rpc", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "test": "bun test tests", + "dev:calculator": "bun run src/calculator.ts", + "dev:app": "bun run src/index.ts", + "deploy": "pnpm turbo run build --filter @prisma/example-hono-rpc... && ( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-composer deploy module.ts ${HONO_RPC_STACK_NAME:+--name \"$HONO_RPC_STACK_NAME\"} ${HONO_RPC_STAGE:+--stage \"$HONO_RPC_STAGE\"} )", + "destroy": "( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-composer destroy module.ts --production ${HONO_RPC_STACK_NAME:+--name \"$HONO_RPC_STACK_NAME\"} )", + "destroy:stage": "( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-composer destroy module.ts --stage \"${HONO_RPC_STAGE:?HONO_RPC_STAGE is required}\" ${HONO_RPC_STACK_NAME:+--name \"$HONO_RPC_STACK_NAME\"} )" + }, + "dependencies": { + "@prisma/composer": "workspace:0.1.0", + "@prisma/composer-prisma-cloud": "workspace:0.1.0", + "arktype": "^2.2.3", + "hono": "^4.12.30" + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "tsdown": "^0.22.4", + "typescript": "^6.0.3" + } +} diff --git a/examples/hono-rpc/prisma-composer.config.ts b/examples/hono-rpc/prisma-composer.config.ts new file mode 100644 index 00000000..7308db8e --- /dev/null +++ b/examples/hono-rpc/prisma-composer.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@prisma/composer/config'; +import { nodeBuild } from '@prisma/composer/node/control'; +import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control'; + +export default defineConfig({ + extensions: [prismaCloud(), nodeBuild()], + state: () => prismaState(), +}); diff --git a/examples/hono-rpc/src/app-service.ts b/examples/hono-rpc/src/app-service.ts new file mode 100644 index 00000000..77998802 --- /dev/null +++ b/examples/hono-rpc/src/app-service.ts @@ -0,0 +1,10 @@ +import node from '@prisma/composer/node'; +import { rpc } from '@prisma/composer/rpc'; +import { compute } from '@prisma/composer-prisma-cloud'; +import { calculatorContract } from './contract.ts'; + +export default compute({ + name: 'app', + deps: { calculator: rpc(calculatorContract) }, + build: node({ module: import.meta.url, entry: '../dist/app/index.mjs' }), +}); diff --git a/examples/hono-rpc/src/calculator-service.ts b/examples/hono-rpc/src/calculator-service.ts new file mode 100644 index 00000000..31ed24f9 --- /dev/null +++ b/examples/hono-rpc/src/calculator-service.ts @@ -0,0 +1,10 @@ +import node from '@prisma/composer/node'; +import { compute } from '@prisma/composer-prisma-cloud'; +import { calculatorContract } from './contract.ts'; + +export default compute({ + name: 'calculator', + deps: {}, + build: node({ module: import.meta.url, entry: '../dist/calculator/calculator.mjs' }), + expose: { rpc: calculatorContract }, +}); diff --git a/examples/hono-rpc/src/calculator.ts b/examples/hono-rpc/src/calculator.ts new file mode 100644 index 00000000..f0c24894 --- /dev/null +++ b/examples/hono-rpc/src/calculator.ts @@ -0,0 +1,19 @@ +import { implement, serve } from '@prisma/composer/rpc'; +import calculatorService from './calculator-service.ts'; +import { calculatorContract } from './contract.ts'; + +const rpc = implement(calculatorContract.router); + +const handler = serve(calculatorService, { + rpc: rpc.router({ + double: rpc.double.handler(({ input }) => ({ + result: input.value * 2, + })), + }), +}); + +const { port } = calculatorService.config(); + +export default handler; + +Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); diff --git a/examples/hono-rpc/src/contract.ts b/examples/hono-rpc/src/contract.ts new file mode 100644 index 00000000..0165af3f --- /dev/null +++ b/examples/hono-rpc/src/contract.ts @@ -0,0 +1,6 @@ +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' })), +}); diff --git a/examples/hono-rpc/src/index.ts b/examples/hono-rpc/src/index.ts new file mode 100644 index 00000000..cb061921 --- /dev/null +++ b/examples/hono-rpc/src/index.ts @@ -0,0 +1,22 @@ +import { Hono } from 'hono'; +import appService from './app-service.ts'; + +const { calculator } = appService.load(); +const { port } = appService.config(); + +const app = new Hono(); + +app.get('/double/:value', async (context) => { + const value = Number(context.req.param('value')); + if (!Number.isFinite(value)) { + return context.json({ error: 'value must be a finite number' }, 400); + } + + const { result } = await calculator.double({ value }, { signal: context.req.raw.signal }); + + return context.json({ result }); +}); + +export default app; + +Bun.serve({ port, hostname: '0.0.0.0', fetch: app.fetch }); diff --git a/examples/hono-rpc/tests/end-to-end.integration.test.ts b/examples/hono-rpc/tests/end-to-end.integration.test.ts new file mode 100644 index 00000000..86be6c52 --- /dev/null +++ b/examples/hono-rpc/tests/end-to-end.integration.test.ts @@ -0,0 +1,37 @@ +/// +import { beforeAll, describe, expect, test } from 'bun:test'; +import { bootstrapService } from '@prisma/composer-prisma-cloud/testing'; +import appService from '../src/app-service.ts'; +import calculatorService from '../src/calculator-service.ts'; + +const CALCULATOR_PORT = 4611; +const APP_PORT = 4612; + +describe('Hono app -> Composer RPC -> calculator', () => { + let app: Awaited>; + + beforeAll(async () => { + const calculator = await bootstrapService(calculatorService, { + service: { port: CALCULATOR_PORT }, + inputs: {}, + }); + app = await bootstrapService(appService, { + service: { port: APP_PORT }, + inputs: { calculator: { url: calculator.url } }, + }); + }); + + test('calls the calculator service', async () => { + const response = await app.fetch(`${app.url}double/21`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ result: 42 }); + }); + + test('rejects invalid input', async () => { + const response = await app.fetch(`${app.url}double/not-a-number`); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'value must be a finite number' }); + }); +}); diff --git a/examples/hono-rpc/tsconfig.json b/examples/hono-rpc/tsconfig.json new file mode 100644 index 00000000..c20f45fa --- /dev/null +++ b/examples/hono-rpc/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["module.ts", "src", "tests"] +} diff --git a/examples/hono-rpc/tsdown.config.ts b/examples/hono-rpc/tsdown.config.ts new file mode 100644 index 00000000..0a2f2249 --- /dev/null +++ b/examples/hono-rpc/tsdown.config.ts @@ -0,0 +1,6 @@ +import { prismaTsDownConfig } from '@prisma/composer/tsdown'; + +export default [ + prismaTsDownConfig({ entry: { calculator: 'src/calculator.ts' }, outDir: 'dist/calculator' }), + prismaTsDownConfig({ entry: { index: 'src/index.ts' }, outDir: 'dist/app' }), +]; diff --git a/examples/hono-rpc/turbo.json b/examples/hono-rpc/turbo.json new file mode 100644 index 00000000..95960709 --- /dev/null +++ b/examples/hono-rpc/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/store/DEMO.md b/examples/store/DEMO.md index ceefcb78..7d3c53d1 100644 --- a/examples/store/DEMO.md +++ b/examples/store/DEMO.md @@ -10,9 +10,9 @@ edges, no infrastructure config anywhere. Point at the wiring: ## 2. A contract — [modules/catalog/src/contract.ts](modules/catalog/src/contract.ts) -The edge type. arktype schemas → a typed client on the consumer side and a -type-checked handler map on the producer side. Validated at the boundary at -runtime. +The edge type. Native oRPC + arktype schemas produce a typed client on the +consumer side and a type-checked `implement()` router on the producer side. +Validated at the boundary at runtime. ## 3. A module — [modules/catalog/src/module.ts](modules/catalog/src/module.ts) @@ -72,8 +72,8 @@ against in-memory fakes on loopback — same contracts, zero cloud. ## 8. Testing story (if time) [page.test.tsx](modules/storefront/app/page.test.tsx): `mockService` swaps -`load()` for typed fakes — the handler maps are type-checked against the same -contracts the real modules serve. No Postgres, no server, no cloud. +`load()` for typed fakes — their native oRPC routers are type-checked against +the same contracts the real modules serve. No Postgres, no server, no cloud. ## Likely questions diff --git a/examples/store/modules/catalog/src/contract.ts b/examples/store/modules/catalog/src/contract.ts index c0fa9534..dc86573a 100644 --- a/examples/store/modules/catalog/src/contract.ts +++ b/examples/store/modules/catalog/src/contract.ts @@ -3,7 +3,7 @@ * consumer imports it and depends on it via `rpc(catalogContract)`, getting * back a typed client. */ -import { contract, rpc } from '@prisma/composer/rpc'; +import { contract, oc } from '@prisma/composer/rpc'; import { type } from 'arktype'; export const product = type({ @@ -16,20 +16,8 @@ export const product = type({ export type Product = typeof product.infer; export const catalogContract = contract({ - listProducts: rpc({ - input: type({}), - output: type({ products: product.array() }), - }), - getProduct: rpc({ - input: type({ id: 'string' }), - output: type({ product: product.or('null') }), - }), - getSpecial: rpc({ - input: type({}), - output: type({ product: product.or('null') }), - }), - rotateSpecial: rpc({ - input: type({}), - output: type({ product: product.or('null') }), - }), + listProducts: oc.input(type({})).output(type({ products: product.array() })), + getProduct: oc.input(type({ id: 'string' })).output(type({ product: product.or('null') })), + getSpecial: oc.input(type({})).output(type({ product: product.or('null') })), + rotateSpecial: oc.input(type({})).output(type({ product: product.or('null') })), }); diff --git a/examples/store/modules/catalog/src/server.ts b/examples/store/modules/catalog/src/server.ts index 409c699e..74de033d 100644 --- a/examples/store/modules/catalog/src/server.ts +++ b/examples/store/modules/catalog/src/server.ts @@ -1,5 +1,5 @@ -import { serve } from '@prisma/composer/rpc'; -import type { Product } from './contract.ts'; +import { implement, serve } from '@prisma/composer/rpc'; +import { catalogContract, type Product } from './contract.ts'; import service from './service.ts'; // load() hydrates `db` into the typed Prisma Next client (ADR-0022) — no SQL, @@ -42,35 +42,35 @@ for (const p of SEED) { } await db.orm.public.Special.upsert({ create: { id: 1, productId: SEED[0].id }, update: {} }); -const handler = serve(service, { - rpc: { - listProducts: async () => ({ - products: await db.orm.public.Product.orderBy((p) => p.name.asc()).all(), - }), - getProduct: async ({ id }) => ({ - product: (await db.orm.public.Product.where({ id }).first()) ?? null, - }), - getSpecial: async () => { - const special = await db.orm.public.Special.where({ id: 1 }).first(); - if (!special) return { product: null }; - const product = await db.orm.public.Product.where({ id: special.productId }).first(); - return { product: product ?? null }; - }, - rotateSpecial: async () => { - const products = await db.orm.public.Product.orderBy((p) => p.name.asc()).all(); - if (products.length === 0) return { product: null }; +const rpc = implement(catalogContract.router); +const router = rpc.router({ + listProducts: rpc.listProducts.handler(async () => ({ + products: await db.orm.public.Product.orderBy((p) => p.name.asc()).all(), + })), + getProduct: rpc.getProduct.handler(async ({ input }) => ({ + product: (await db.orm.public.Product.where({ id: input.id }).first()) ?? null, + })), + getSpecial: rpc.getSpecial.handler(async () => { + const special = await db.orm.public.Special.where({ id: 1 }).first(); + if (!special) return { product: null }; + const product = await db.orm.public.Product.where({ id: special.productId }).first(); + return { product: product ?? null }; + }), + rotateSpecial: rpc.rotateSpecial.handler(async () => { + const products = await db.orm.public.Product.orderBy((p) => p.name.asc()).all(); + if (products.length === 0) return { product: null }; - const special = await db.orm.public.Special.where({ id: 1 }).first(); - const currentIdx = products.findIndex((p) => p.id === special?.productId); - const next = products[(currentIdx + 1) % products.length]; - await db.orm.public.Special.upsert({ - create: { id: 1, productId: next.id }, - update: { productId: next.id }, - }); - return { product: next }; - }, - }, + const special = await db.orm.public.Special.where({ id: 1 }).first(); + const currentIdx = products.findIndex((p) => p.id === special?.productId); + const next = products[(currentIdx + 1) % products.length]; + await db.orm.public.Special.upsert({ + create: { id: 1, productId: next.id }, + update: { productId: next.id }, + }); + return { product: next }; + }), }); +const handler = serve(service, { rpc: router }); export default handler; // Bind all interfaces — Compute routes external HTTP to the VM, so a diff --git a/examples/store/modules/catalog/testing/fake.ts b/examples/store/modules/catalog/testing/fake.ts index e5578815..63ebc33d 100644 --- a/examples/store/modules/catalog/testing/fake.ts +++ b/examples/store/modules/catalog/testing/fake.ts @@ -1,11 +1,11 @@ /** * An in-memory catalog for TESTING a module that depends on it — no Postgres, - * no deploy. It serves the real `catalogContract`, so its handler map is + * no deploy. It implements the real `catalogContract`, so its native router is * type-checked against the same contract the real catalog exposes. Test-only, * deliberately outside `src/`, so it never rides into the deployed artifact. */ import node from '@prisma/composer/node'; -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; import { compute } from '@prisma/composer-prisma-cloud'; import { catalogContract, type Product } from '../src/contract.ts'; @@ -23,16 +23,17 @@ const fakeCatalog = compute({ let specialIdx = 0; -export default serve(fakeCatalog, { - rpc: { - listProducts: async () => ({ products: FAKE_PRODUCTS }), - getProduct: async ({ id }) => ({ - product: FAKE_PRODUCTS.find((p) => p.id === id) ?? null, - }), - getSpecial: async () => ({ product: FAKE_PRODUCTS[specialIdx] }), - rotateSpecial: async () => { - specialIdx = (specialIdx + 1) % FAKE_PRODUCTS.length; - return { product: FAKE_PRODUCTS[specialIdx] }; - }, - }, +const rpc = implement(catalogContract.router); +const router = rpc.router({ + listProducts: rpc.listProducts.handler(() => ({ products: FAKE_PRODUCTS })), + getProduct: rpc.getProduct.handler(({ input }) => ({ + product: FAKE_PRODUCTS.find((p) => p.id === input.id) ?? null, + })), + getSpecial: rpc.getSpecial.handler(() => ({ product: FAKE_PRODUCTS[specialIdx] })), + rotateSpecial: rpc.rotateSpecial.handler(() => { + specialIdx = (specialIdx + 1) % FAKE_PRODUCTS.length; + return { product: FAKE_PRODUCTS[specialIdx] }; + }), }); + +export default serve(fakeCatalog, { rpc: router }); diff --git a/examples/store/modules/orders/src/contract.ts b/examples/store/modules/orders/src/contract.ts index dce9258f..e015dccc 100644 --- a/examples/store/modules/orders/src/contract.ts +++ b/examples/store/modules/orders/src/contract.ts @@ -2,7 +2,7 @@ * orders' public RPC contract. `placeOrder` snapshots the product's name and * price at placement time — later catalog changes don't rewrite history. */ -import { contract, rpc } from '@prisma/composer/rpc'; +import { contract, oc } from '@prisma/composer/rpc'; import { type } from 'arktype'; export const order = type({ @@ -17,12 +17,8 @@ export const order = type({ export type Order = typeof order.infer; export const ordersContract = contract({ - placeOrder: rpc({ - input: type({ productId: 'string', quantity: 'number' }), - output: type({ order: order.or('null') }), - }), - listOrders: rpc({ - input: type({}), - output: type({ orders: order.array() }), - }), + placeOrder: oc + .input(type({ productId: 'string', quantity: 'number' })) + .output(type({ order: order.or('null') })), + listOrders: oc.input(type({})).output(type({ orders: order.array() })), }); diff --git a/examples/store/modules/orders/src/server.ts b/examples/store/modules/orders/src/server.ts index 2c33fa74..07db94a0 100644 --- a/examples/store/modules/orders/src/server.ts +++ b/examples/store/modules/orders/src/server.ts @@ -1,4 +1,5 @@ -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; +import { ordersContract } from './contract.ts'; import service from './service.ts'; // load() hydrates both deps: `db` is the Prisma Next typed client (ADR-0022), @@ -12,30 +13,30 @@ const { port } = service.config(); process.on('uncaughtException', (err) => console.error('uncaughtException', err)); process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); -const handler = serve(service, { - rpc: { - placeOrder: async ({ productId, quantity }) => { - const { product } = await catalog.getProduct({ id: productId }); - if (product === null || quantity < 1) return { order: null }; +const rpc = implement(ordersContract.router); +const router = rpc.router({ + placeOrder: rpc.placeOrder.handler(async ({ input }) => { + const { product } = await catalog.getProduct({ id: input.productId }); + if (product === null || input.quantity < 1) return { order: null }; - const row = await db.orm.public.Order.create({ - productId: product.id, - productName: product.name, - quantity, - totalCents: product.priceCents * quantity, - }); - return { order: { ...row, placedAt: new Date(row.placedAt).toISOString() } }; - }, - listOrders: async () => { - const rows = await db.orm.public.Order.orderBy((o) => o.placedAt.desc()) - .take(20) - .all(); - return { - orders: rows.map((o) => ({ ...o, placedAt: new Date(o.placedAt).toISOString() })), - }; - }, - }, + const row = await db.orm.public.Order.create({ + productId: product.id, + productName: product.name, + quantity: input.quantity, + totalCents: product.priceCents * input.quantity, + }); + return { order: { ...row, placedAt: new Date(row.placedAt).toISOString() } }; + }), + listOrders: rpc.listOrders.handler(async () => { + const rows = await db.orm.public.Order.orderBy((o) => o.placedAt.desc()) + .take(20) + .all(); + return { + orders: rows.map((o) => ({ ...o, placedAt: new Date(o.placedAt).toISOString() })), + }; + }), }); +const handler = serve(service, { rpc: router }); export default handler; // Bind all interfaces — Compute routes external HTTP to the VM, so a diff --git a/examples/store/modules/orders/testing/fake.ts b/examples/store/modules/orders/testing/fake.ts index 3ca35193..e2061674 100644 --- a/examples/store/modules/orders/testing/fake.ts +++ b/examples/store/modules/orders/testing/fake.ts @@ -1,11 +1,11 @@ /** * An in-memory orders service for TESTING a module that depends on it — no - * Postgres, no catalog, no deploy. It serves the real `ordersContract`, so its - * handler map is type-checked against the same contract the real orders + * Postgres, no catalog, no deploy. It implements the real `ordersContract`, so + * its native router is type-checked against the same contract the real orders * exposes. Test-only, deliberately outside `src/`. */ import node from '@prisma/composer/node'; -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; import { compute } from '@prisma/composer-prisma-cloud'; import { type Order, ordersContract } from '../src/contract.ts'; @@ -27,18 +27,19 @@ const fakeOrders = compute({ expose: { rpc: ordersContract }, }); -export default serve(fakeOrders, { - rpc: { - placeOrder: async ({ productId, quantity }) => ({ - order: { - id: `order-${FAKE_ORDERS.length + 1}`, - productId, - productName: productId, - quantity, - totalCents: 100 * quantity, - placedAt: '2026-07-13T09:00:00.000Z', - }, - }), - listOrders: async () => ({ orders: FAKE_ORDERS }), - }, +const rpc = implement(ordersContract.router); +const router = rpc.router({ + placeOrder: rpc.placeOrder.handler(({ input }) => ({ + order: { + id: `order-${FAKE_ORDERS.length + 1}`, + productId: input.productId, + productName: input.productId, + quantity: input.quantity, + totalCents: 100 * input.quantity, + placedAt: '2026-07-13T09:00:00.000Z', + }, + })), + listOrders: rpc.listOrders.handler(() => ({ orders: FAKE_ORDERS })), }); + +export default serve(fakeOrders, { rpc: router }); diff --git a/examples/store/scripts/dev.ts b/examples/store/scripts/dev.ts index f02c2666..59f8523b 100644 --- a/examples/store/scripts/dev.ts +++ b/examples/store/scripts/dev.ts @@ -6,7 +6,7 @@ * the same contracts the real modules serve. */ import node from '@prisma/composer/node'; -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; import { compute } from '@prisma/composer-prisma-cloud'; import fakeCatalogHandler, { FAKE_PRODUCTS } from '@store/catalog/fake'; import { type Order, ordersContract } from '@store/orders/contract'; @@ -21,27 +21,27 @@ const ordersNode = compute({ build: node({ module: import.meta.url, entry: 'dev.ts' }), expose: { rpc: ordersContract }, }); +const rpc = implement(ordersContract.router); +const router = rpc.router({ + placeOrder: rpc.placeOrder.handler(({ input }) => { + const product = FAKE_PRODUCTS.find((p) => p.id === input.productId); + if (!product || input.quantity < 1) return { order: null }; + const order: Order = { + id: crypto.randomUUID(), + productId: product.id, + productName: product.name, + quantity: input.quantity, + totalCents: product.priceCents * input.quantity, + placedAt: new Date().toISOString(), + }; + placed.unshift(order); + return { order }; + }), + listOrders: rpc.listOrders.handler(() => ({ orders: placed })), +}); const orders = Bun.serve({ port: 0, - fetch: serve(ordersNode, { - rpc: { - placeOrder: async ({ productId, quantity }) => { - const product = FAKE_PRODUCTS.find((p) => p.id === productId); - if (!product || quantity < 1) return { order: null }; - const order: Order = { - id: crypto.randomUUID(), - productId: product.id, - productName: product.name, - quantity, - totalCents: product.priceCents * quantity, - placedAt: new Date().toISOString(), - }; - placed.unshift(order); - return { order }; - }, - listOrders: async () => ({ orders: placed }), - }, - }), + fetch: serve(ordersNode, { rpc: router }), }); console.log(`fake catalog ${catalog.url}`); diff --git a/examples/storefront-auth/modules/auth/src/contract.ts b/examples/storefront-auth/modules/auth/src/contract.ts index 42d79c81..b82ef438 100644 --- a/examples/storefront-auth/modules/auth/src/contract.ts +++ b/examples/storefront-auth/modules/auth/src/contract.ts @@ -3,12 +3,12 @@ * auth exposes and serves it (service.ts / server.ts); a consumer imports it and * depends on it via `rpc(authContract)`, getting back a typed client. */ -import { contract, rpc } from '@prisma/composer/rpc'; +import { contract, oc } from '@prisma/composer/rpc'; import { type } from 'arktype'; export const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), // Non-leaking proof that auth received its injected `AUTH_SIGNING_SECRET` // (ADR-0029): returns ONLY a boolean, never the secret value. - secretCheck: rpc({ input: type({}), output: type({ ok: 'boolean' }) }), + secretCheck: oc.input(type({})).output(type({ ok: 'boolean' })), }); diff --git a/examples/storefront-auth/modules/auth/src/server.ts b/examples/storefront-auth/modules/auth/src/server.ts index 1175d6f4..737085db 100644 --- a/examples/storefront-auth/modules/auth/src/server.ts +++ b/examples/storefront-auth/modules/auth/src/server.ts @@ -3,8 +3,9 @@ // re-keyed the platform environment address-free, so service.load() below // reads it directly, with no address. -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; import { SQL } from 'bun'; +import { authContract } from './contract.ts'; import service from './service.ts'; const { db } = service.load(); // db: PostgresConfig — the app owns its client @@ -35,22 +36,24 @@ process.on('unhandledRejection', (err) => console.error('unhandledRejection', er // Prove the DB is reachable; a failed ping returns `{ ok: false }` rather // than throwing, so the platform sees an unhealthy dependency, not a crash. -const handler = serve(service, { - rpc: { - verify: async () => { - try { - await sql`select 1`; - return { ok: true }; - } catch (err) { - console.error('db query failed', err); - return { ok: false }; - } - }, - // True iff the injected secret matches the expected marker — proof it was - // provisioned and double-looked-up, without ever returning the value. - secretCheck: async () => ({ ok: signingKey.expose() === EXPECTED_SIGNING_SECRET }), - }, +const rpc = implement(authContract.router); +const router = rpc.router({ + verify: rpc.verify.handler(async () => { + try { + await sql`select 1`; + return { ok: true }; + } catch (err) { + console.error('db query failed', err); + return { ok: false }; + } + }), + // True iff the injected secret matches the expected marker — proof it was + // provisioned and double-looked-up, without ever returning the value. + secretCheck: rpc.secretCheck.handler(() => ({ + ok: signingKey.expose() === EXPECTED_SIGNING_SECRET, + })), }); +const handler = serve(service, { rpc: router }); export default handler; // Bind all interfaces — Compute routes external HTTP to the VM, so a diff --git a/examples/storefront-auth/modules/auth/testing/fake.ts b/examples/storefront-auth/modules/auth/testing/fake.ts index c24d1b80..4b558955 100644 --- a/examples/storefront-auth/modules/auth/testing/fake.ts +++ b/examples/storefront-auth/modules/auth/testing/fake.ts @@ -1,7 +1,7 @@ /** * A dummy auth service for TESTING a module that depends on auth — inject it in * place of the real one so a consumer's tests need no Postgres and no deploy. - * It serves the real `authContract` (so its handler map is type-checked against + * It implements the real `authContract` (so its native router is type-checked against * the same contract the real auth exposes) with an in-memory `verify`. A * test-only entrypoint, deliberately outside `src/`, so it can never ride into * the deployed artifact. @@ -13,7 +13,7 @@ */ import node from '@prisma/composer/node'; -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; import { compute } from '@prisma/composer-prisma-cloud'; import { authContract } from '../src/contract.ts'; @@ -24,12 +24,13 @@ const fakeAuth = compute({ expose: { rpc: authContract }, }); -export default serve(fakeAuth, { - rpc: { - verify: async ({ token }) => ({ ok: token.length > 0 }), - // The fake stands in for the auth dependency's wiring, not its secret: it - // always proves the round trip. The real secret injection is proven by the - // live auth service in the CI E2E. - secretCheck: async () => ({ ok: true }), - }, +const rpc = implement(authContract.router); +const router = rpc.router({ + verify: rpc.verify.handler(({ input }) => ({ ok: input.token.length > 0 })), + // The fake stands in for the auth dependency's wiring, not its secret: it + // always proves the round trip. The real secret injection is proven by the + // live auth service in the CI E2E. + secretCheck: rpc.secretCheck.handler(() => ({ ok: true })), }); + +export default serve(fakeAuth, { rpc: router }); diff --git a/packages/0-framework/2-authoring/rpc/package.json b/packages/0-framework/2-authoring/rpc/package.json index 371997e6..e79accc9 100644 --- a/packages/0-framework/2-authoring/rpc/package.json +++ b/packages/0-framework/2-authoring/rpc/package.json @@ -16,6 +16,9 @@ "dependencies": { "@internal/core": "workspace:0.1.0", "@internal/foundation": "workspace:0.1.0", + "@orpc/client": "2.0.0-beta.18", + "@orpc/contract": "2.0.0-beta.18", + "@orpc/server": "2.0.0-beta.18", "@standard-schema/spec": "^1.1.0", "arktype": "^2.2.3" }, diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/client.test.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/client.test.ts index 5de4fe61..55aa913c 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/client.test.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/client.test.ts @@ -1,22 +1,33 @@ import { describe, expect, test } from 'bun:test'; +import { ORPCError } from '@orpc/client'; +import { oc } from '@orpc/contract'; import { type } from 'arktype'; import { makeClient } from '../client.ts'; import { contract } from '../contract.ts'; -import { rpc } from '../rpc.ts'; const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), + session: { + revoke: oc.input(type({ id: 'string' })).output(type({ revoked: 'boolean' })), + }, }); +const rpcOutput = (output: unknown, status = 200) => + new Response(JSON.stringify({ json: output }), { + status, + headers: { 'content-type': 'application/json' }, + }); + +const rpcError = (code: string, message: string, status: number) => + rpcOutput({ defined: false, inferable: false, code, message }, status); + describe('makeClient()', () => { test('POSTs JSON to /rpc/ and returns the validated output', async () => { const requests: Request[] = []; const client = makeClient(authContract, 'http://auth.internal', { fetch: async (req) => { requests.push(req); - return new Response(JSON.stringify({ ok: true }), { - headers: { 'content-type': 'application/json' }, - }); + return rpcOutput({ ok: true }); }, }); @@ -26,7 +37,7 @@ describe('makeClient()', () => { expect(requests).toHaveLength(1); expect(requests[0]?.method).toBe('POST'); expect(requests[0]?.url).toBe('http://auth.internal/rpc/verify'); - expect(await requests[0]?.json()).toEqual({ token: 't' }); + expect(await requests[0]?.json()).toEqual({ json: { token: 't' } }); }); test('a base URL with its own path is preserved, not dropped — a leading-slash-free join', async () => { @@ -34,9 +45,7 @@ describe('makeClient()', () => { const client = makeClient(authContract, 'http://auth.internal/api/v1', { fetch: async (req) => { requests.push(req); - return new Response(JSON.stringify({ ok: true }), { - headers: { 'content-type': 'application/json' }, - }); + return rpcOutput({ ok: true }); }, }); @@ -45,32 +54,37 @@ describe('makeClient()', () => { expect(requests[0]?.url).toBe('http://auth.internal/api/v1/rpc/verify'); }); - test('rejects a response that fails the output schema — a lying server is caught', async () => { + test('preserves native nested oRPC router paths', async () => { + const requests: Request[] = []; const client = makeClient(authContract, 'http://auth.internal', { - fetch: async () => - new Response(JSON.stringify({ ok: 'not-a-boolean' }), { - headers: { 'content-type': 'application/json' }, - }), + fetch: async (req) => { + requests.push(req); + return rpcOutput({ revoked: true }); + }, }); - await expect(client.verify({ token: 't' })).rejects.toThrow(); + await expect(client.session.revoke({ id: 'session-1' })).resolves.toEqual({ revoked: true }); + expect(requests[0]?.url).toBe('http://auth.internal/rpc/session/revoke'); }); - test('throws naming the method when the transport responds non-OK', async () => { + test('decodes structured RPC errors with their stable code', async () => { const client = makeClient(authContract, 'http://auth.internal', { - fetch: async () => new Response('nope', { status: 500 }), + fetch: async () => rpcError('INTERNAL_SERVER_ERROR', 'Internal Server Error', 500), }); - await expect(client.verify({ token: 't' })).rejects.toThrow(/verify/); + try { + await client.verify({ token: 't' }); + throw new Error('expected the RPC call to fail'); + } catch (error) { + expect(error).toBeInstanceOf(ORPCError); + if (!(error instanceof ORPCError)) throw error; + expect(error.code).toBe('INTERNAL_SERVER_ERROR'); + } }); - test("a non-2xx response's { error } body is folded into the thrown message", async () => { + test("a non-2xx RPC error's intentional public message is preserved", async () => { const client = makeClient(authContract, 'http://auth.internal', { - fetch: async () => - new Response(JSON.stringify({ error: 'token expired' }), { - status: 401, - headers: { 'content-type': 'application/json' }, - }), + fetch: async () => rpcError('UNAUTHORIZED', 'token expired', 401), }); await expect(client.verify({ token: 't' })).rejects.toThrow(/token expired/); @@ -90,9 +104,7 @@ describe('makeClient()', () => { serviceKey: 'edge-key', fetch: async (req) => { requests.push(req); - return new Response(JSON.stringify({ ok: true }), { - headers: { 'content-type': 'application/json' }, - }); + return rpcOutput({ ok: true }); }, }); @@ -106,9 +118,7 @@ describe('makeClient()', () => { const client = makeClient(authContract, 'http://auth.internal', { fetch: async (req) => { requests.push(req); - return new Response(JSON.stringify({ ok: true }), { - headers: { 'content-type': 'application/json' }, - }); + return rpcOutput({ ok: true }); }, }); @@ -116,4 +126,19 @@ describe('makeClient()', () => { expect(requests[0]?.headers.has('authorization')).toBe(false); }); + + test('propagates an AbortSignal through the generated client transport', async () => { + const controller = new AbortController(); + let seenSignal: AbortSignal | undefined; + const client = makeClient(authContract, 'http://auth.internal', { + fetch: async (req) => { + seenSignal = req.signal; + return rpcOutput({ ok: true }); + }, + }); + + await client.verify({ token: 't' }, { signal: controller.signal }); + + expect(seenSignal?.aborted).toBe(controller.signal.aborted); + }); }); diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/contract-satisfaction.test-d.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/contract-satisfaction.test-d.ts index 783f5e03..24987ba2 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/contract-satisfaction.test-d.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/contract-satisfaction.test-d.ts @@ -11,6 +11,8 @@ */ import type { BuildAdapter, Contract, ModuleBuilder } from '@internal/core'; import { dependency, service, string } from '@internal/core'; +import { blindCast } from '@internal/foundation/casts'; +import { oc } from '@orpc/contract'; import { type } from 'arktype'; import { expectTypeOf, test } from 'vitest'; import { contract } from '../contract.ts'; @@ -24,31 +26,25 @@ const build: BuildAdapter = { }; const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); // candidate providers (standing in for provisioned refs' exposed contracts) const exact = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); const extraOut = contract({ - verify: rpc({ - input: type({ token: 'string' }), - output: type({ ok: 'boolean', user: 'string' }), - }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean', user: 'string' })), }); const extraMethod = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), - refresh: rpc({ input: type({ rt: 'string' }), output: type({ token: 'string' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), + refresh: oc.input(type({ rt: 'string' })).output(type({ token: 'string' })), }); const extraInput = contract({ - verify: rpc({ - input: type({ token: 'string', tenant: 'string' }), - output: type({ ok: 'boolean' }), - }), + verify: oc.input(type({ token: 'string', tenant: 'string' })).output(type({ ok: 'boolean' })), }); const missing = contract({ - whoami: rpc({ input: type({}), output: type({ id: 'string' }) }), + whoami: oc.input(type({})).output(type({ id: 'string' })), }); // a second protocol kind, standing in for one @prisma/composer/rpc knows nothing @@ -58,7 +54,7 @@ declare function wsContract< Fns extends Record Promise>, >(fns: Fns): Contract<'ws', Fns>; const wrongKind = wsContract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: async (_input: { token: string }) => ({ ok: true }), }); // an untyped dependency end — http()'s shape (Req = unknown, the escape hatch). @@ -143,7 +139,10 @@ test('an incompatible ref-port for a required rpc slot does not compile', () => }); test('the derived client is typed both ways', () => { - const auth = null as unknown as Client; + const auth = blindCast< + Client, + 'type-only placeholder used to assert the inferred client surface' + >(null); expectTypeOf(auth.verify).toBeCallableWith({ token: 't' }); expectTypeOf(auth.verify({ token: 't' })).resolves.toExtend<{ ok: boolean }>(); // @ts-expect-error unknown method diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/contract.test.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/contract.test.ts index 1b412b96..130c278b 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/contract.test.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/contract.test.ts @@ -1,36 +1,44 @@ import { describe, expect, test } from 'bun:test'; +import { blindCast } from '@internal/foundation/casts'; +import { oc } from '@orpc/contract'; +import { getHiddenRouterContract, implement } from '@orpc/server'; import { type } from 'arktype'; import { contract } from '../contract.ts'; -import { rpc } from '../rpc.ts'; - -describe('rpc()', () => { - test('carries the input/output schemas on the runtime value', () => { - const input = type({ token: 'string' }); - const output = type({ ok: 'boolean' }); - - const verify = rpc({ input, output }) as unknown as { input: unknown; output: unknown }; - - expect(verify.input).toBe(input); - expect(verify.output).toBe(output); - }); -}); describe('contract()', () => { - test('is branded with kind "rpc" and carries the function map as __cmp', () => { - const fns = { - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + test('brands and retains the exact native oRPC router', () => { + const router = { + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }; - const authContract = contract(fns); + const authContract = contract(router); expect(authContract.kind).toBe('rpc'); - expect(authContract.__cmp).toBe(fns); + expect(authContract.router).toBe(router); + expect( + blindCast< + unknown, + '__cmp has a deliberately erased runtime representation; this asserts it retains the router identity' + >(authContract.__cmp), + ).toBe(router); + }); + + test('the retained router works directly with native oRPC implement()', () => { + const authContract = contract({ + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), + }); + const os = implement(authContract.router); + const router = os.router({ + verify: os.verify.handler(({ input }) => ({ ok: input.token.length > 0 })), + }); + + expect(getHiddenRouterContract(router)).toBe(authContract.router); }); test('satisfies() is nominal — a contract only satisfies itself', () => { const build = () => contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); const authContract = build(); const structurallyEqual = build(); @@ -41,7 +49,7 @@ describe('contract()', () => { test('the returned contract is frozen', () => { const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); expect(Object.isFrozen(authContract)).toBe(true); diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/invariants.test.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/invariants.test.ts index 4c6e2a0d..3b4de422 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/invariants.test.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/invariants.test.ts @@ -41,7 +41,7 @@ describe('invariant: the entry is web-standard only — no bun/node coupling', ( const js = await out.outputs[0]!.text(); // Positive marker: the probe genuinely bundled this package's serve() logic. - expect(js).toContain('RPC dispatch is flat'); + expect(js).toContain('no native oRPC router supplied'); for (const token of ['from "bun"', '"node:']) { expect(js).not.toContain(token); } diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/rpc-connection.test.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/rpc-connection.test.ts index 6427a697..79b38997 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/rpc-connection.test.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/rpc-connection.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from 'bun:test'; import { isNode, string } from '@internal/core'; +import { oc } from '@orpc/contract'; import { type } from 'arktype'; import { contract } from '../contract.ts'; import { perBindingToken, RPC_PEER_KEY, rpc } from '../rpc.ts'; const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); describe('rpc(contract) — the dependency end', () => { diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/serve-handlers.test-d.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/serve-handlers.test-d.ts index 52c6a359..f3b8907d 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/serve-handlers.test-d.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/serve-handlers.test-d.ts @@ -1,25 +1,21 @@ /** - * serve(service, handlers) forces an exhaustive, correctly-typed handler map - * straight off the service's `expose` and `load()`'s return — a missing or - * mistyped handler must not compile. - * - * Type-only (vitest `--typecheck`, never executed). The positive cases stay - * direct `serve(...)` calls: the handler callbacks get their parameter types - * by contextual inference from serve's signature, which `toBeCallableWith` - * does not flow into a standalone argument — the call itself is the - * assertion. The negative handler shapes keep a `// @ts-expect-error` on the - * offending line. + * Native oRPC's implementer owns procedure exhaustiveness and handler typing; + * Composer's serve() owns the exhaustive mapping from exposed ports to those + * implemented routers. */ import type { DependencyEnd, RunnableServiceNode } from '@internal/core'; import { dependency, service } from '@internal/core'; +import { blindCast } from '@internal/foundation/casts'; +import { oc } from '@orpc/contract'; +import { implement } from '@orpc/server'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type } from 'arktype'; -import { test } from 'vitest'; +import { expectTypeOf, test } from 'vitest'; import { contract } from '../contract.ts'; -import { rpc } from '../rpc.ts'; import { serve } from '../serve.ts'; const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); interface FakeDb { @@ -52,36 +48,71 @@ declare const authService: RunnableServiceNode< { rpc: typeof authContract } >; -test('the exhaustive, correctly-typed handler map is accepted', () => { - serve(authService, { - rpc: { - verify: async ({ token }, { db }) => ({ ok: token.length > 0 && db.validTokens.length >= 0 }), - }, +const os = implement(authContract.router); + +test('native implement() types input/output and serve() accepts the complete router', () => { + const router = os.router({ + verify: os.verify.handler(({ input }) => { + expectTypeOf(input).toEqualTypeOf<{ token: string }>(); + return { ok: input.token.length > 0 }; + }), }); + + serve(authService, { rpc: router }); }); -test('extra handler methods/ports beyond what is exposed are allowed (width)', () => { - serve(authService, { - rpc: { - verify: async ({ token }, _deps) => ({ ok: token.length > 0 }), - extra: async (_input: { note: string }, _deps: unknown) => ({ handled: true }), - }, - extraPort: { - anything: async (_input: unknown, _deps: unknown) => 1, - }, +test('native implement() rejects missing and mistyped procedures', () => { + // @ts-expect-error native oRPC requires the contract's verify procedure + os.router({}); + + os.router({ + // @ts-expect-error output ok must be boolean + verify: os.verify.handler(() => ({ ok: 'yes' })), }); -}); -test('a missing or mistyped handler does not compile', () => { - // @ts-expect-error missing the required "verify" handler for the exposed "rpc" port - serve(authService, { rpc: {} }); + os.verify.handler(({ input }) => { + // @ts-expect-error native oRPC inferred token as string + const token: number = input.token; + return { ok: token > 0 }; + }); +}); - // @ts-expect-error missing the exposed "rpc" port entirely +test('serve() requires one native router for every exposed RPC port', () => { + // @ts-expect-error missing the exposed rpc port serve(authService, {}); +}); + +declare const transformedInput: StandardSchemaV1; +declare const transformedOutput: StandardSchemaV1<{ doubled: number }, { doubled: string }>; + +const transformedContract = contract({ + transform: oc.input(transformedInput).output(transformedOutput), +}); +const transformed = implement(transformedContract.router); + +declare const transformedService: RunnableServiceNode< + typeof node.inputs, + typeof node.params, + { rpc: typeof transformedContract } +>; + +test('native handler and client honor Standard Schema transformations', () => { + const router = transformed.router({ + transform: transformed.transform.handler(({ input }) => { + expectTypeOf(input).toEqualTypeOf(); + return { doubled: input * 2 }; + }), + }); + serve(transformedService, { rpc: router }); - // @ts-expect-error wrong input shape (token must be a string, not a number) - serve(authService, { rpc: { verify: async (_input: { token: number }) => ({ ok: true }) } }); + type TransformedClient = import('../rpc.ts').Client; + const client = blindCast< + TransformedClient, + 'type-only placeholder used to assert the transformed client surface' + >(null); + expectTypeOf(client.transform).toBeCallableWith('21'); + expectTypeOf(client.transform('21')).resolves.toEqualTypeOf<{ doubled: string }>(); - // @ts-expect-error wrong output shape (ok must be a boolean, not a string) - serve(authService, { rpc: { verify: async ({ token }, _deps) => ({ ok: token }) } }); + // @ts-expect-error handler returns the output schema input, not transformed client output + transformed.transform.handler(({ input }) => ({ doubled: String(input * 2) })); }); diff --git a/packages/0-framework/2-authoring/rpc/src/__tests__/serve.test.ts b/packages/0-framework/2-authoring/rpc/src/__tests__/serve.test.ts index 3393a7a7..b1d0245b 100644 --- a/packages/0-framework/2-authoring/rpc/src/__tests__/serve.test.ts +++ b/packages/0-framework/2-authoring/rpc/src/__tests__/serve.test.ts @@ -1,25 +1,43 @@ import { afterEach, describe, expect, test } from 'bun:test'; import type { DependencyEnd, RunnableServiceNode } from '@internal/core'; import { dependency, service } from '@internal/core'; +import { blindCast } from '@internal/foundation/casts'; +import { ORPCError } from '@orpc/client'; +import { oc } from '@orpc/contract'; +import { implement } from '@orpc/server'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type } from 'arktype'; import { makeClient } from '../client.ts'; import { contract } from '../contract.ts'; -import { rpc } from '../rpc.ts'; import { RPC_ACCEPTED_KEYS_ENV, serve } from '../serve.ts'; const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); +const auth = implement(authContract.router); + +type VerifyHandler = Parameters[0]; + +function authRouter(handler: VerifyHandler) { + return auth.router({ verify: auth.verify.handler(handler) }); +} + +const rpcRequest = ( + method: string, + input: unknown, + init?: { headers?: Record; verb?: string; prefix?: string }, +) => + new Request(`http://auth.internal${init?.prefix ?? '/rpc'}/${method}`, { + method: init?.verb ?? 'POST', + headers: { 'content-type': 'application/json', ...init?.headers }, + body: init?.verb === 'GET' ? undefined : JSON.stringify({ json: input }), + }); interface FakeDb { readonly validTokens: readonly string[]; } -/** - * A fake RunnableServiceNode exposing authContract — stands in for compute()'s - * node. `db` hydrates through a real DependencyEnd (not an override cast), so - * `load()`'s return is a genuine `Loaded`, matching production shape. - */ +/** A concrete test service with the same load/config/expose shape as Compute. */ function fakeAuthService(load: () => FakeDb) { const db: DependencyEnd = dependency({ name: 'db', @@ -41,22 +59,22 @@ function fakeAuthService(load: () => FakeDb) { expose: { rpc: authContract }, }); - return { + return blindCast< + RunnableServiceNode, + 'test service supplies the runnable run/load members that service() deliberately leaves target-specific' + >({ ...node, run: (_address: string, boot: () => Promise) => boot(), load: () => ({ db: load() }), - } as unknown as RunnableServiceNode< - typeof node.inputs, - typeof node.params, - { rpc: typeof authContract } - >; + }); } -describe('serve()', () => { - test('round trip: a valid call reaches the handler and returns the typed result', async () => { +describe('serve() with native oRPC routers', () => { + test('round trip: native client -> native router -> validated result', async () => { const authService = fakeAuthService(() => ({ validTokens: ['good-token'] })); + const { db } = authService.load(); const handler = serve(authService, { - rpc: { verify: async ({ token }, { db }) => ({ ok: db.validTokens.includes(token) }) }, + rpc: authRouter(({ input }) => ({ ok: db.validTokens.includes(input.token) })), }); const client = makeClient(authContract, 'http://auth.internal', { fetch: handler }); @@ -64,94 +82,111 @@ describe('serve()', () => { await expect(client.verify({ token: 'bad-token' })).resolves.toEqual({ ok: false }); }); - test('a bad input is rejected — the server-side arktype validation fires', async () => { + test('rejects bad input through the native oRPC contract schema', async () => { const authService = fakeAuthService(() => ({ validTokens: [] })); - const handler = serve(authService, { rpc: { verify: async () => ({ ok: true }) } }); + const handler = serve(authService, { rpc: authRouter(() => ({ ok: true })) }); - const res = await handler( - new Request('http://auth.internal/rpc/verify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ token: 123 }), - }), - ); + const res = await handler(rpcRequest('verify', { token: 123 })); expect(res.status).toBe(400); }); - test('an unknown method 404s', async () => { + test('returns 404 for an unknown procedure', async () => { const authService = fakeAuthService(() => ({ validTokens: [] })); - const handler = serve(authService, { rpc: { verify: async () => ({ ok: true }) } }); + const handler = serve(authService, { rpc: authRouter(() => ({ ok: true })) }); - const res = await handler( - new Request('http://auth.internal/rpc/doesNotExist', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({}), - }), - ); + const res = await handler(rpcRequest('doesNotExist', {})); expect(res.status).toBe(404); }); - test('a handler throw is a 500, not a crash', async () => { + test('masks an unexpected handler exception', async () => { const authService = fakeAuthService(() => ({ validTokens: [] })); const handler = serve(authService, { - rpc: { - verify: async () => { - throw new Error('db unreachable'); - }, - }, + rpc: authRouter(() => { + throw new Error('db unreachable'); + }), }); - const res = await handler( - new Request('http://auth.internal/rpc/verify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ token: 't' }), - }), - ); + const res = await handler(rpcRequest('verify', { token: 't' })); expect(res.status).toBe(500); + expect(await res.text()).not.toContain('db unreachable'); }); - test('the wrong HTTP verb on a known method is a 405', async () => { + test('returns 405 for the wrong verb on a known procedure', async () => { const authService = fakeAuthService(() => ({ validTokens: [] })); - const handler = serve(authService, { rpc: { verify: async () => ({ ok: true }) } }); + const handler = serve(authService, { rpc: authRouter(() => ({ ok: true })) }); - const res = await handler(new Request('http://auth.internal/rpc/verify', { method: 'GET' })); + const res = await handler(rpcRequest('verify', undefined, { verb: 'GET' })); expect(res.status).toBe(405); }); - test('calls load() exactly once, not per request', async () => { - let loadCalls = 0; - const authService = fakeAuthService(() => { - loadCalls += 1; - return { validTokens: ['t'] }; + test('rejects a router implemented from a different contract instance', () => { + const structurallyEqual = contract({ + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); - const handler = serve(authService, { - rpc: { verify: async ({ token }, { db }) => ({ ok: db.validTokens.includes(token) }) }, + const other = implement(structurallyEqual.router); + const wrongRouter = other.router({ + verify: other.verify.handler(() => ({ ok: true })), }); - const client = makeClient(authContract, 'http://auth.internal', { fetch: handler }); + const authService = fakeAuthService(() => ({ validTokens: [] })); + + expect(() => serve(authService, { rpc: wrongRouter })).toThrow(/exact contract\.router/); + }); + + test('never exposes procedures added outside the topology contract', async () => { + const authService = fakeAuthService(() => ({ validTokens: [] })); + const router = Object.assign( + authRouter(() => ({ ok: true })), + { + admin: auth.verify.handler(() => ({ ok: true })), + }, + ); + const handler = serve(authService, { rpc: router }); + + const declared = await handler(rpcRequest('verify', { token: 't' })); + const undeclared = await handler(rpcRequest('admin', { token: 't' })); - await client.verify({ token: 't' }); - await client.verify({ token: 't' }); + expect(declared.status).toBe(200); + expect(undeclared.status).toBe(404); + }); - expect(loadCalls).toBe(1); + test('preserves native nested router paths', async () => { + const nestedContract = contract({ + session: { + revoke: oc.input(type({ id: 'string' })).output(type({ revoked: 'boolean' })), + }, + }); + const os = implement(nestedContract.router); + const router = os.router({ + session: os.session.router({ + revoke: os.session.revoke.handler(({ input }) => ({ revoked: input.id.length > 0 })), + }), + }); + const base = fakeAuthService(() => ({ validTokens: [] })); + const nestedService = blindCast< + RunnableServiceNode, + 'test replaces only the exposed contract on an otherwise complete runnable service' + >({ ...base, expose: { rpc: nestedContract } }); + const handler = serve(nestedService, { rpc: router }); + const client = makeClient(nestedContract, 'http://auth.internal', { fetch: handler }); + + await expect(client.session.revoke({ id: 'session-1' })).resolves.toEqual({ revoked: true }); }); }); -describe('serve() — service-key enforcement (COMPOSER_RPC_ACCEPTED_KEYS)', () => { +describe('serve() service-key enforcement', () => { afterEach(() => { delete process.env[RPC_ACCEPTED_KEYS_ENV]; }); - test('a member key dispatches normally', async () => { + test('accepts a member key', async () => { process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['good-key']); const authService = fakeAuthService(() => ({ validTokens: ['good-token'] })); const handler = serve(authService, { - rpc: { verify: async ({ token }, { db }) => ({ ok: db.validTokens.includes(token) }) }, + rpc: authRouter(({ input }) => ({ ok: input.token === 'good-token' })), }); const client = makeClient(authContract, 'http://auth.internal', { fetch: handler, @@ -161,136 +196,250 @@ describe('serve() — service-key enforcement (COMPOSER_RPC_ACCEPTED_KEYS)', () await expect(client.verify({ token: 'good-token' })).resolves.toEqual({ ok: true }); }); - test('a wrong key is rejected with 401 and the handler never runs', async () => { + test('rejects a wrong key before dispatch', async () => { process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['good-key']); - let handlerCalled = false; + let called = false; const authService = fakeAuthService(() => ({ validTokens: [] })); const handler = serve(authService, { - rpc: { - verify: async () => { - handlerCalled = true; - return { ok: true }; - }, - }, + rpc: authRouter(() => { + called = true; + return { ok: true }; + }), }); const res = await handler( - new Request('http://auth.internal/rpc/verify', { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: 'Bearer wrong-key' }, - body: JSON.stringify({ token: 't' }), - }), + rpcRequest('verify', { token: 't' }, { headers: { authorization: 'Bearer wrong-key' } }), ); expect(res.status).toBe(401); - expect(handlerCalled).toBe(false); + expect(called).toBe(false); }); - test('a missing key is rejected with 401 and the handler never runs', async () => { + test('rejects a missing key before input validation', async () => { process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['good-key']); - let handlerCalled = false; + let called = false; const authService = fakeAuthService(() => ({ validTokens: [] })); const handler = serve(authService, { - rpc: { - verify: async () => { - handlerCalled = true; - return { ok: true }; - }, - }, + rpc: authRouter(() => { + called = true; + return { ok: true }; + }), }); - const res = await handler( - new Request('http://auth.internal/rpc/verify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ token: 't' }), - }), - ); + const res = await handler(rpcRequest('verify', { token: 123 })); expect(res.status).toBe(401); - expect(handlerCalled).toBe(false); + expect(called).toBe(false); }); - test('401 fires before input validation — a bad-input body with no key is 401, not 400', async () => { - process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['good-key']); + test('a provisioned empty key set denies every caller', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify([]); const authService = fakeAuthService(() => ({ validTokens: [] })); - const handler = serve(authService, { rpc: { verify: async () => ({ ok: true }) } }); + const handler = serve(authService, { rpc: authRouter(() => ({ ok: true })) }); - const res = await handler( - new Request('http://auth.internal/rpc/verify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ token: 123 }), - }), - ); + expect((await handler(rpcRequest('verify', { token: 't' }))).status).toBe(401); + }); - expect(res.status).toBe(401); + test('an unset key set is the open local/test state', async () => { + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve(authService, { rpc: authRouter(() => ({ ok: true })) }); + + expect((await handler(rpcRequest('verify', { token: 't' }))).status).toBe(200); }); - test('a provisioned empty accepted-keys array ("[]") denies every caller — a provider with zero wired consumers', async () => { - process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify([]); - let handlerCalled = false; - const authService = fakeAuthService(() => ({ validTokens: ['good-token'] })); - const handler = serve(authService, { - rpc: { - verify: async ({ token }, { db }) => { - handlerCalled = true; - return { ok: db.validTokens.includes(token) }; - }, - }, - }); + test('malformed JSON fails closed', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = 'not-json{'; + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve(authService, { rpc: authRouter(() => ({ ok: true })) }); - const res = await handler( - new Request('http://auth.internal/rpc/verify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ token: 'good-token' }), + expect((await handler(rpcRequest('verify', { token: 't' }))).status).toBe(401); + }); + + test('a configured empty string fails closed', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = ''; + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve(authService, { rpc: authRouter(() => ({ ok: true })) }); + + expect((await handler(rpcRequest('verify', { token: 't' }))).status).toBe(401); + }); + + test('an empty key inside the configured set fails closed', async () => { + process.env[RPC_ACCEPTED_KEYS_ENV] = JSON.stringify(['']); + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve(authService, { rpc: authRouter(() => ({ ok: true })) }); + + expect((await handler(rpcRequest('verify', { token: 't' }))).status).toBe(401); + }); +}); + +describe('serve() production transport behavior', () => { + test('round-trips an intentional oRPC error code and public message', async () => { + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve(authService, { + rpc: authRouter(() => { + throw new ORPCError('CONFLICT', { message: 'token was already consumed' }); }), - ); + }); + const client = makeClient(authContract, 'http://auth.internal', { fetch: handler }); - expect(res.status).toBe(401); - expect(handlerCalled).toBe(false); + try { + await client.verify({ token: 't' }); + throw new Error('expected the RPC call to fail'); + } catch (error) { + expect(error).toBeInstanceOf(ORPCError); + if (!(error instanceof ORPCError)) throw error; + expect(error.code).toBe('CONFLICT'); + expect(error.message).toBe('token was already consumed'); + } }); - test('an unset accepted-keys env var passes through unchanged — the unprovisioned local/test state', async () => { - const authService = fakeAuthService(() => ({ validTokens: ['good-token'] })); + test('rejects an invalid native handler result at the provider boundary', async () => { + const authService = fakeAuthService(() => ({ validTokens: [] })); const handler = serve(authService, { - rpc: { verify: async ({ token }, { db }) => ({ ok: db.validTokens.includes(token) }) }, + rpc: authRouter(() => + blindCast< + { ok: boolean }, + 'test deliberately violates the declared output at runtime to prove provider validation' + >({ ok: 'not-a-boolean' }), + ), }); - const res = await handler( - new Request('http://auth.internal/rpc/verify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ token: 'good-token' }), - }), - ); + const response = await handler(rpcRequest('verify', { token: 't' })); - expect(res.status).toBe(200); + expect(response.status).toBe(500); + expect(await response.text()).not.toContain('not-a-boolean'); }); - test('malformed JSON in the accepted-keys env var denies every caller — fails closed', async () => { - process.env[RPC_ACCEPTED_KEYS_ENV] = 'not-json{'; - let handlerCalled = false; - const authService = fakeAuthService(() => ({ validTokens: ['good-token'] })); - const handler = serve(authService, { - rpc: { - verify: async ({ token }, { db }) => { - handlerCalled = true; - return { ok: db.validTokens.includes(token) }; - }, + test('applies Standard Schema input/output transforms once through native oRPC', async () => { + const input: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'composer-test', + validate: async (value) => + typeof value === 'string' + ? { value: Number(value) } + : { issues: [{ message: 'expected a string' }] }, + }, + }; + const output: StandardSchemaV1<{ doubled: number }, { doubled: string }> = { + '~standard': { + version: 1, + vendor: 'composer-test', + validate: async (value) => + typeof value === 'object' && + value !== null && + 'doubled' in value && + typeof value.doubled === 'number' + ? { value: { doubled: String(value.doubled) } } + : { issues: [{ message: 'expected a numeric doubled field' }] }, }, + }; + const transformedContract = contract({ transform: oc.input(input).output(output) }); + const os = implement(transformedContract.router); + const router = os.router({ + transform: os.transform.handler(({ input: value }) => ({ doubled: value * 2 })), }); + const base = fakeAuthService(() => ({ validTokens: [] })); + const transformedService = blindCast< + RunnableServiceNode< + typeof base.inputs, + typeof base.params, + { rpc: typeof transformedContract } + >, + 'test replaces only the exposed contract on an otherwise complete runnable service' + >({ ...base, expose: { rpc: transformedContract } }); + const handler = serve(transformedService, { rpc: router }); + const client = makeClient(transformedContract, 'http://auth.internal', { fetch: handler }); + + await expect(client.transform('21')).resolves.toEqual({ doubled: '42' }); + }); - const res = await handler( - new Request('http://auth.internal/rpc/verify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ token: 'good-token' }), - }), + test('rejects a body above the configured byte limit', async () => { + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve( + authService, + { rpc: authRouter(() => ({ ok: true })) }, + { maxBodySize: 16 }, ); - expect(res.status).toBe(401); - expect(handlerCalled).toBe(false); + const res = await handler(rpcRequest('verify', { token: 'body-that-is-too-large' })); + + expect(res.status).toBe(413); + }); + + test('supports an explicit mount prefix without path guessing', async () => { + const authService = fakeAuthService(() => ({ validTokens: [] })); + const handler = serve( + authService, + { rpc: authRouter(() => ({ ok: true })) }, + { prefix: '/api/v1/rpc' }, + ); + + const mounted = await handler(rpcRequest('verify', { token: 't' }, { prefix: '/api/v1/rpc' })); + const defaultPath = await handler(rpcRequest('verify', { token: 't' })); + + expect(mounted.status).toBe(200); + expect(defaultPath.status).toBe(404); + }); + + test('rejects duplicate native procedure paths across exposed ports', () => { + const secondContract = contract({ + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), + }); + const second = implement(secondContract.router); + const secondRouter = second.router({ + verify: second.verify.handler(() => ({ ok: true })), + }); + const base = fakeAuthService(() => ({ validTokens: [] })); + const multiPortService = blindCast< + RunnableServiceNode< + typeof base.inputs, + typeof base.params, + { rpc: typeof authContract; admin: typeof secondContract } + >, + 'test replaces only the exposed contracts on an otherwise complete runnable service' + >({ ...base, expose: { rpc: authContract, admin: secondContract } }); + + expect(() => + serve(multiPortService, { + rpc: authRouter(() => ({ ok: true })), + admin: secondRouter, + }), + ).toThrow(/procedure path "verify"/); + }); + + test('distinguishes an encoded slash in one segment from a nested path', async () => { + const slashContract = contract({ + 'a/b': oc.input(type({})).output(type({ route: "'slash'" })), + }); + const nestedContract = contract({ + a: { b: oc.input(type({})).output(type({ route: "'nested'" })) }, + }); + const slash = implement(slashContract.router); + const nested = implement(nestedContract.router); + const slashRouter = slash.router({ + 'a/b': slash['a/b'].handler(() => ({ route: 'slash' })), + }); + const nestedRouter = nested.router({ + a: nested.a.router({ + b: nested.a.b.handler(() => ({ route: 'nested' })), + }), + }); + const base = fakeAuthService(() => ({ validTokens: [] })); + const serviceWithBoth = blindCast< + RunnableServiceNode< + typeof base.inputs, + typeof base.params, + { slash: typeof slashContract; nested: typeof nestedContract } + >, + 'test replaces only the exposed contracts on an otherwise complete runnable service' + >({ ...base, expose: { slash: slashContract, nested: nestedContract } }); + const handler = serve(serviceWithBoth, { slash: slashRouter, nested: nestedRouter }); + + const slashResponse = await handler(rpcRequest('a%2Fb', {})); + const nestedResponse = await handler(rpcRequest('a/b', {})); + + expect(slashResponse.status).toBe(200); + expect(nestedResponse.status).toBe(200); }); }); diff --git a/packages/0-framework/2-authoring/rpc/src/client.ts b/packages/0-framework/2-authoring/rpc/src/client.ts index 10a2e044..50b7e4c3 100644 --- a/packages/0-framework/2-authoring/rpc/src/client.ts +++ b/packages/0-framework/2-authoring/rpc/src/client.ts @@ -1,90 +1,49 @@ /** - * The RPC kind's network adapter — the client `rpc(contract)` hydrates to. - * Reads each method's Standard Schema pair off the contract's `__cmp[method]` - * runtime value (rpc()'s `{ input, output }`), POSTs JSON to - * `/rpc/`, and validates the response against the output schema - * before returning it (a provider can be typed-compatible and still lie at - * runtime — this is the per-call layer that catches that). When a - * `serviceKey` is supplied (ADR-0030), every request also carries - * `Authorization: Bearer `. + * Composer's oRPC client transport adapter. Composer supplies the provider URL + * and per-edge service key; the returned client is oRPC's native inferred + * client for the contract router. */ -import type { Contract } from '@internal/core'; import { blindCast } from '@internal/foundation/casts'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { Client, RpcFns } from './rpc.ts'; -import { standardValidate } from './standard-schema.ts'; - -/** rpc()'s runtime shape for one method: `{ input, output }` wearing a function's type. */ -interface MethodSchemas { - readonly input: StandardSchemaV1; - readonly output: StandardSchemaV1; -} +import { createORPCClient } from '@orpc/client'; +import { RPCLink } from '@orpc/client/fetch'; +import type { AnyRpcContract } from './contract.ts'; +import type { Client } from './rpc.ts'; /** - * A fetch-shaped transport. Defaults to the real `fetch`; a served handler - * (`serve()`'s return value) works too — the binding does not have to be a - * network hop. + * A fetch-shaped transport. Defaults to real `fetch`; a Composer `serve()` + * handler can be supplied directly for in-process tests. */ export type Transport = (req: Request) => Promise; -/** `/rpc/`, preserving a base URL's own path (e.g. a mount point). */ -function methodUrl(base: string, method: string): string { - const normalizedBase = base.endsWith('/') ? base : `${base}/`; - return new URL(`rpc/${method}`, normalizedBase).toString(); +function baseUrl(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url; } -/** The server's `{ error }` body, if the response has one — undefined otherwise. */ -async function errorDetail(res: Response): Promise { - try { - const body: unknown = await res.json(); - return typeof body === 'object' && body !== null && 'error' in body - ? String(body.error) - : undefined; - } catch { - return undefined; - } -} - -export function makeClient>( +export function makeClient( contract: C, url: string, opts?: { fetch?: Transport; serviceKey?: string }, ): Client { - const send = opts?.fetch ?? fetch; - const headers: Record = { 'content-type': 'application/json' }; - if (opts?.serviceKey !== undefined) { - headers['Authorization'] = `Bearer ${opts.serviceKey}`; - } - const client: Record Promise> = {}; - - for (const [method, schemas] of Object.entries( - blindCast< - Record, - 'rpc() stores each method input/output Standard Schemas on the function value; RpcFns types only the call signature' - >(contract.__cmp), - )) { - client[method] = async (input: unknown) => { - const res = await send( - new Request(methodUrl(url, method), { - method: 'POST', - headers, - body: JSON.stringify(input), + const transport = opts?.fetch; + const link = new RPCLink({ + origin: baseUrl(url), + url: '/rpc', + headers: opts?.serviceKey === undefined ? {} : { authorization: `Bearer ${opts.serviceKey}` }, + ...(transport === undefined + ? {} + : { + fetch: (requestUrl: string, init: RequestInit) => + transport(new Request(requestUrl, init)), }), - ); - if (!res.ok) { - const detail = await errorDetail(res); - throw new Error( - `RPC call "${method}" failed: ${res.status} ${res.statusText}` + - (detail !== undefined ? ` — ${detail}` : ''), - ); - } - return standardValidate(schemas.output, await res.json()); - }; - } + }); + // The contract is intentionally a type-and-topology input here. Native oRPC + // performs input/output validation on the provider router and transports the + // already-transformed result with its RPC codec. + void contract; return blindCast< Client, - 'client is assembled dynamically from the contract methods; each entry matches Client by construction' - >(client); + 'createORPCClient dynamically proxies the exact native router carried by C' + >(createORPCClient(link)); } diff --git a/packages/0-framework/2-authoring/rpc/src/contract.ts b/packages/0-framework/2-authoring/rpc/src/contract.ts index 50815159..f1cf10c7 100644 --- a/packages/0-framework/2-authoring/rpc/src/contract.ts +++ b/packages/0-framework/2-authoring/rpc/src/contract.ts @@ -1,19 +1,63 @@ /** - * The RPC kind's Contract builder. Its Cmp is the concrete function map - * `Fns` (each entry built by `rpc()`), never a mapped type over a schema map - * — that is what makes @prisma/composer's plain assignability check apply real - * function variance (contravariant input, covariant output). Load-time - * compatibility is nominal: a value only satisfies itself. + * Composer's topology wrapper around a native oRPC contract router. + * + * oRPC owns procedure schemas, typed errors, metadata, nesting, and client + * inference. Composer adds only the Contract shape core needs to compare and + * wire a provider port to a consumer dependency. */ import type { Contract } from '@internal/core'; +import { blindCast } from '@internal/foundation/casts'; +import type { RouterContract, RouterContractClient } from '@orpc/contract'; -export function contract< - // biome-ignore lint/suspicious/noExplicitAny: concrete function-map bound, matches contract-satisfaction.poc.ts. - Fns extends Record Promise>, ->(fns: Fns): Contract<'rpc', Fns> { - const value: Contract<'rpc', Fns> = { +/** A method-bearing native oRPC contract router accepted by Composer. */ +export type RpcRouterContract = Readonly>; + +/** A Composer RPC contract retaining its native oRPC router unchanged. */ +export type RpcContract = Contract< + 'rpc', + RouterContractClient +> & { + /** The native router passed to oRPC's `implement()` and ecosystem tooling. */ + readonly router: R; +}; + +/** Erased RPC contract bound used where the concrete native router is unknown. */ +export type AnyRpcContract = Contract<'rpc', unknown> & { + readonly router: RpcRouterContract; +}; + +/** Extracts the native oRPC router from a Composer RPC contract. */ +export type RouterOf = C extends RpcContract ? R : never; + +export function isRpcContract(value: unknown): value is AnyRpcContract { + return ( + typeof value === 'object' && + value !== null && + 'kind' in value && + value.kind === 'rpc' && + '__cmp' in value && + 'satisfies' in value && + 'router' in value && + typeof value.router === 'object' && + value.router !== null + ); +} + +/** + * Makes a native oRPC contract router available to Composer's topology. + * Procedure authoring remains the standard `oc.input(...).output(...)` API. + */ +export function contract(router: R): RpcContract { + let value: RpcContract; + value = { kind: 'rpc', - __cmp: fns, + // Core never reads __cmp at runtime. Its concrete native client shape makes + // TypeScript apply method width plus input/output variance at wiring sites. + __cmp: blindCast< + RouterContractClient, + 'RouterContractClient is the client createORPCClient produces for this exact native router' + >(router), + router, satisfies: (required) => value === required, }; return Object.freeze(value); diff --git a/packages/0-framework/2-authoring/rpc/src/index.ts b/packages/0-framework/2-authoring/rpc/src/index.ts index d736b4d9..9c591cb9 100644 --- a/packages/0-framework/2-authoring/rpc/src/index.ts +++ b/packages/0-framework/2-authoring/rpc/src/index.ts @@ -1,16 +1,20 @@ /** - * The RPC kind: `contract()` + `rpc()` build a Contract whose Cmp is a - * concrete function map; `rpc(contract)` (the connection-end overload) - * hydrates a consumer's dependency to `Client` over the network binding in - * client.ts; `serve()` generates a provider's fetch handler straight off a - * service's `expose`. All web-standard (fetch/Request/Response) — runs - * anywhere those exist, no node/bun coupling. + * Native oRPC contract authoring and implementation, wrapped by Composer's + * topology, hydration, and service-to-service authorization. All transport + * code is Web-standard (fetch/Request/Response). */ +export type { ORPCErrorCode as RpcErrorCode } from '@orpc/client'; +export { ORPCError as RpcError } from '@orpc/client'; +export type { RouterContract, RouterContractClient } from '@orpc/contract'; +export { oc } from '@orpc/contract'; +export type { ContractedRouter } from '@orpc/server'; +export { implement } from '@orpc/server'; export type { Transport } from './client.ts'; export { makeClient } from './client.ts'; +export type { RouterOf, RpcContract, RpcRouterContract } from './contract.ts'; export { contract } from './contract.ts'; export type { Client } from './rpc.ts'; export { perBindingToken, RPC_PEER_KEY, rpc } from './rpc.ts'; -export type { Handlers } from './serve.ts'; -export { RPC_ACCEPTED_KEYS_ENV, serve } from './serve.ts'; +export type { Routers, ServeOptions } from './serve.ts'; +export { DEFAULT_RPC_MAX_BODY_SIZE, RPC_ACCEPTED_KEYS_ENV, serve } from './serve.ts'; diff --git a/packages/0-framework/2-authoring/rpc/src/rpc.ts b/packages/0-framework/2-authoring/rpc/src/rpc.ts index 3a2b0442..49790e88 100644 --- a/packages/0-framework/2-authoring/rpc/src/rpc.ts +++ b/packages/0-framework/2-authoring/rpc/src/rpc.ts @@ -1,43 +1,26 @@ /** - * `rpc()` is the RPC kind's single entry, overloaded by what it's given. A - * `{ input, output }` pair types one contract method as a concrete - * `(input) => Promise` — the shape that makes Contract's plain - * assignability apply real function variance. Input/output are Standard - * Schema validators (arktype the canonical one); the runtime value carries - * the two schemas for serve()/the client to read back out. - * - * A Contract instead types a dependency end — the typed sibling - * of `http()`, same `{ url }` param plus an optional `serviceKey` (the - * per-binding auth token ADR-0030 wires through here as a provisioning need, - * ADR-0031), hydrating to the typed client `Client` over the network - * binding in client.ts. It carries the contract as its `required` value, so - * `ModuleBuilder.provision`'s wiring is checked against it (compile time) and - * Load's `satisfies()` backstop re-checks it (runtime). + * `rpc(contract)` declares a Composer dependency on a native oRPC contract. + * Dependency hydration supplies oRPC's inferred client over the provider URL + * and the per-binding Prisma service key. */ import type { Contract, ProvisionNeed } from '@internal/core'; import { type DependencyEnd, dependency, provisionNeed, string } from '@internal/core'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; import { makeClient } from './client.ts'; +import type { AnyRpcContract } from './contract.ts'; +import { isRpcContract } from './contract.ts'; -/** ADR-0031's need brand for RPC's per-binding service key — the target registers a provisioner under this. */ +/** ADR-0031's need brand for RPC's per-binding service key. */ export const RPC_PEER_KEY: unique symbol = Symbol.for('prisma:rpc/per-binding-key'); -/** The provisioning need `rpc()`'s `serviceKey` param declares (ADR-0030): a shared, unguessable value the target mints per consumer edge. */ +/** The provisioning need `rpc()`'s `serviceKey` parameter declares. */ export const perBindingToken = (): ProvisionNeed => provisionNeed(RPC_PEER_KEY); -/** The concrete function-map bound every RPC Contract's Cmp must fit. */ -// biome-ignore lint/suspicious/noExplicitAny: concrete function-map bound, matches contract()'s own (see contract.ts). -export type RpcFns = Record Promise>; - -export function rpc(m: { - input: I; - output: O; -}): (input: StandardSchemaV1.InferInput) => Promise>; -export function rpc>(contract: C): DependencyEnd, C>; -export function rpc( - arg: { input: StandardSchemaV1; output: StandardSchemaV1 } | Contract<'rpc', RpcFns>, -): unknown { - if (!isRpcContract(arg)) return arg; +export function rpc(contract: C): DependencyEnd, C> { + if (!isRpcContract(contract)) { + throw new TypeError( + 'rpc(): expected a Composer contract created with contract(nativeOrpcRouter).', + ); + } return dependency({ type: 'rpc', @@ -46,22 +29,11 @@ export function rpc( url: string(), serviceKey: string({ optional: true, provision: perBindingToken() }), }, - hydrate: ({ url, serviceKey }) => makeClient(arg, url, { serviceKey }), + hydrate: ({ url, serviceKey }) => makeClient(contract, url, { serviceKey }), }, - required: arg, + required: contract, }); } -function isRpcContract(value: unknown): value is Contract<'rpc', RpcFns> { - return ( - typeof value === 'object' && - value !== null && - 'kind' in value && - value.kind === 'rpc' && - '__cmp' in value && - 'satisfies' in value - ); -} - -/** The typed client a consumer's `rpc(contract)` dependency hydrates to. */ +/** The native oRPC client a consumer's `rpc(contract)` dependency hydrates to. */ export type Client = C extends Contract ? Cmp : never; diff --git a/packages/0-framework/2-authoring/rpc/src/serve.ts b/packages/0-framework/2-authoring/rpc/src/serve.ts index cbc1c739..ca2abe79 100644 --- a/packages/0-framework/2-authoring/rpc/src/serve.ts +++ b/packages/0-framework/2-authoring/rpc/src/serve.ts @@ -1,87 +1,71 @@ /** - * Generates the RPC server from a service's `expose`: a web fetch handler - * that dispatches `POST /rpc/` across every exposed port, flattened - * into one method namespace (method names must be unique across a service's - * ports — there is no port segment in the route). `Handlers` derives the - * exhaustive, correctly-typed handler map straight off `S["expose"]` and - * `S["load"]`'s return, so an incomplete or mistyped `serve(service, - * handlers)` call does not compile; extra handler methods/ports are allowed - * (width, same as a provider exposing more than a consumer requires). - * - * Per ADR-0030, every request is checked against the accepted service-key - * set before dispatch: unset (never provisioned — local/test) passes - * through; a provisioned `"[]"` (deployed, zero wired consumers) denies - * every caller; a provisioned non-empty set requires membership via - * `Authorization: Bearer `. + * Mounts native oRPC routers for a Composer service. oRPC owns procedures, + * middleware, validation, typed errors, codecs, and dispatch. Composer owns + * topology-to-router verification, per-edge authorization, and body limits. */ -import type { Contract, Expose, RunnableServiceNode } from '@internal/core'; +import type { RunnableServiceNode } from '@internal/core'; import { blindCast } from '@internal/foundation/casts'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; -import { standardValidate } from './standard-schema.ts'; +import { ORPCError } from '@orpc/client'; +import { + type AnyRouter, + type ContractedRouter, + type DefaultInitialContext, + getHiddenRouterContract, + walkProcedureContractsSync, +} from '@orpc/server'; +import { RPCHandler } from '@orpc/server/fetch'; +import { RequestLimitHandlerPlugin } from '@orpc/server/plugins'; +import { type AnyRpcContract, isRpcContract, type RpcContract } from './contract.ts'; // The ambient environment of whatever runtime hosts the bundle. Declared -// structurally so this entry imports no runtime's types. +// structurally so this entry imports no runtime-specific types. declare const process: { env: Record }; -/** The reserved env var the target (slice 2) writes the accepted key set to. */ +/** The reserved env var the target writes the accepted key set to. */ export const RPC_ACCEPTED_KEYS_ENV = 'COMPOSER_RPC_ACCEPTED_KEYS'; -// biome-ignore lint/suspicious/noExplicitAny: accepts any concrete runnable service node — generics are invariant, so `any` is required (mirrors ModuleBuilder.provision in @prisma/composer). -type AnyRunnable = RunnableServiceNode; - -type CmpOf = C extends Contract ? Cmp : never; - -type HandlerFor = Fn extends (input: infer I) => Promise - ? (input: I, deps: LoadedDeps) => Promise - : never; - -/** Every exposed port's methods, turned into a handler map typed off S's own `expose` and `load()`. */ -export type Handlers = { - [Port in keyof NonNullable]: { - [M in keyof CmpOf[Port]>]: HandlerFor< - CmpOf[Port]>[M], - ReturnType - >; - }; -}; +/** The default maximum encoded request body: one mebibyte. */ +export const DEFAULT_RPC_MAX_BODY_SIZE = 1024 * 1024; -interface MethodSchemas { - readonly input: StandardSchemaV1; - readonly output: StandardSchemaV1; +export interface ServeOptions { + /** Mount path for the RPC router. @default '/rpc' */ + readonly prefix?: `/${string}`; + /** Maximum encoded request body in bytes. @default 1048576 */ + readonly maxBodySize?: number; } -type RpcHandler = (input: unknown, deps: unknown) => Promise; +// biome-ignore lint/suspicious/noExplicitAny: accepts every concrete runnable node; its generics are invariant. +type AnyRunnable = RunnableServiceNode; -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }); -} +type RouterFor = + C extends RpcContract ? ContractedRouter : never; -/** The provisioned accepted key set, or undefined when the deploy never provisioned one (local/test — enforcement off). */ +/** One native implemented oRPC router for every RPC port exposed by a service. */ +export type Routers = { + [Port in keyof NonNullable as NonNullable[Port] extends AnyRpcContract + ? Port + : never]: RouterFor[Port]>; +}; + +/** The provisioned accepted key set, or undefined in unprovisioned local/test runs. */ function acceptedKeys(): readonly string[] | undefined { const raw = process.env[RPC_ACCEPTED_KEYS_ENV]; - if (raw === undefined || raw === '') return undefined; // unprovisioned → pass through + if (raw === undefined) return undefined; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { - return []; // provisioned but unreadable → deny all + return []; } - return Array.isArray(parsed) && parsed.every((key): key is string => typeof key === 'string') + return Array.isArray(parsed) && + parsed.every((key): key is string => typeof key === 'string' && key.length > 0) ? parsed : []; } -/** - * Length-independent constant-time string equality — no early exit on the - * first mismatched character or on a length difference, so a caller cannot - * time its way toward a valid key. No `node:crypto`, to keep this module - * runtime-agnostic. - */ +/** Runtime-agnostic length-independent equality for service capability keys. */ function constantTimeEquals(a: string, b: string): boolean { const length = Math.max(a.length, b.length); let diff = a.length ^ b.length; @@ -91,7 +75,6 @@ function constantTimeEquals(a: string, b: string): boolean { return diff === 0; } -/** Whether `presented` is a member of `accepted` — always compares against every key. */ function isAcceptedKey(presented: string, accepted: readonly string[]): boolean { let matched = false; for (const key of accepted) { @@ -102,106 +85,132 @@ function isAcceptedKey(presented: string, accepted: readonly string[]): boolean const BEARER_PREFIX = 'Bearer '; -/** The bearer token on `Authorization`, or `''` if the header is missing or malformed. */ -function bearerToken(req: Request): string { - const header = req.headers.get('authorization'); - return header?.startsWith(BEARER_PREFIX) ? header.slice(BEARER_PREFIX.length) : ''; +function bearerToken(header: string | string[] | undefined): string { + const value = Array.isArray(header) ? header.at(-1) : header; + return value?.startsWith(BEARER_PREFIX) ? value.slice(BEARER_PREFIX.length) : ''; } -/** - * Flattens every exposed port's methods into one method → {schemas, handler} - * table. RPC dispatch is flat (`/rpc/`), so a method name exposed by - * more than one port is a construction-time error, as is a missing handler. - */ -function methodTable( - expose: Expose, - handlers: Record>, -): Map { - const table = new Map(); - - for (const [port, contract] of Object.entries(expose)) { - const portHandlers = handlers[port] ?? {}; - for (const [method, fn] of Object.entries(contract.__cmp)) { - if (table.has(method)) { +function validateOptions(options: ServeOptions | undefined): Required { + const prefix = options?.prefix ?? '/rpc'; + const maxBodySize = options?.maxBodySize ?? DEFAULT_RPC_MAX_BODY_SIZE; + if (!prefix.startsWith('/')) { + throw new Error('serve(): prefix must start with "/".'); + } + if (!Number.isSafeInteger(maxBodySize) || maxBodySize <= 0) { + throw new Error('serve(): maxBodySize must be a positive safe integer.'); + } + return { prefix, maxBodySize }; +} + +interface MountedRouter { + readonly port: string; + readonly handler: RPCHandler; +} + +function procedurePathKey(path: readonly string[]): string { + return JSON.stringify(path); +} + +function procedureWirePath(path: readonly string[]): string { + return path.map(encodeURIComponent).join('/'); +} + +function mountRouters( + service: S, + routers: Routers, + maxBodySize: number, +): MountedRouter[] { + const mounted: MountedRouter[] = []; + const paths = new Map(); + const routersByPort = blindCast< + Record, + 'Routers is keyed by the string names of the service RPC ports' + >(routers); + + for (const [port, exposedContract] of Object.entries(service.expose ?? {})) { + if (!isRpcContract(exposedContract)) continue; + + const router = routersByPort[port]; + if (router === undefined) { + throw new Error(`serve(): no native oRPC router supplied for exposed port "${port}".`); + } + if (getHiddenRouterContract(router) !== exposedContract.router) { + throw new Error( + `serve(): router for port "${port}" was not implemented from that port's exact contract.router.`, + ); + } + + walkProcedureContractsSync(exposedContract.router, (_procedure, path) => { + const key = procedurePathKey(path); + const owner = paths.get(key); + if (owner !== undefined) { throw new Error( - `serve(): method "${method}" is exposed by more than one port — RPC dispatch is flat ` + - "(POST /rpc/), so method names must be unique across a service's exposed ports.", + `serve(): procedure path "${procedureWirePath(path)}" is exposed by both "${owner}" and "${port}"; ` + + "paths must be unique across a service's RPC ports.", ); } - const handler = portHandlers[method]; - if (handler === undefined) { - throw new Error(`serve(): no handler supplied for exposed method "${port}.${method}".`); - } - const { input, output } = blindCast< - MethodSchemas, - 'rpc() stores the method input/output Standard Schemas on the function value; the Cmp type models only the call signature' - >(fn); - table.set(method, { input, output, handler }); - } + paths.set(key, port); + }); + + const declaredPaths = new Set( + [...paths.entries()].filter(([, owner]) => owner === port).map(([path]) => path), + ); + + mounted.push({ + port, + handler: new RPCHandler(router, { + // `ContractedRouter` deliberately permits richer structural router + // types. Only publish procedures declared by this topology contract, + // even if an implementation object carries additional procedures. + filter: (_procedure, path) => declaredPaths.has(procedurePathKey(path)), + plugins: [new RequestLimitHandlerPlugin({ maxBodySize })], + interceptors: [ + async ({ next, request }) => { + const accepted = acceptedKeys(); + const presented = bearerToken(request.headers['authorization']); + if (accepted !== undefined && !isAcceptedKey(presented, accepted)) { + throw new ORPCError('UNAUTHORIZED', { + message: 'Unauthorized: missing or invalid service key', + }); + } + if (request.method !== 'POST') { + throw new ORPCError('METHOD_NOT_SUPPORTED', { + message: 'RPC procedures require POST', + }); + } + return next(); + }, + ], + }), + }); } - return table; + return mounted; +} + +function notFound(req: Request): Response { + const { pathname } = new URL(req.url); + return Response.json({ error: `Not found: ${pathname}` }, { status: 404 }); } /** - * Routes `POST /rpc/`: parses JSON, validates input, calls the - * handler with `service.load()`'s deps, validates the output, and responds - * JSON. An unknown method or invalid input is a 4xx; a handler (or output - * validation) failure is a 5xx — either way the process does not crash. - * `load()` is called exactly once, here, before the handler ever runs. + * Returns a Web Fetch handler for the supplied native oRPC routers. Matching + * and authorization happen before body decoding. Nested oRPC paths remain + * intact below the `/rpc` prefix. */ -export function serve>( +export function serve( service: S, - handlers: H, + routers: Routers>, + options?: ServeOptions, ): (req: Request) => Promise { - const table = methodTable( - service.expose ?? {}, - blindCast< - Record>, - 'Handlers is the exhaustive typed handler map; methodTable indexes it by the runtime port/method strings' - >(handlers), - ); - const deps = service.load(); + const { prefix, maxBodySize } = validateOptions(options); + const mounted = mountRouters(service, routers, maxBodySize); return async (req: Request): Promise => { - const accepted = acceptedKeys(); - if (accepted !== undefined && !isAcceptedKey(bearerToken(req), accepted)) { - return jsonResponse({ error: 'Unauthorized: missing or invalid service key' }, 401); - } - - const { pathname } = new URL(req.url); - const methodName = /^\/rpc\/([^/]+)$/.exec(pathname)?.[1]; - if (methodName === undefined) { - return jsonResponse({ error: `Not found: ${pathname}` }, 404); - } - - const method = table.get(methodName); - if (method === undefined) { - return jsonResponse({ error: `Unknown RPC method "${methodName}"` }, 404); - } - if (req.method !== 'POST') { - return jsonResponse({ error: `Method "${methodName}" requires POST` }, 405); - } - - let body: unknown; - try { - body = await req.json(); - } catch { - return jsonResponse({ error: 'Request body must be JSON' }, 400); - } - - let input: unknown; - try { - input = await standardValidate(method.input, body); - } catch (err) { - return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 400); - } - - try { - const result = await method.handler(input, deps); - return jsonResponse(await standardValidate(method.output, result)); - } catch (err) { - return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 500); + for (const { handler } of mounted) { + const result = await handler.handle(req, { context: {}, prefix }); + if (result.matched) return result.response; } + return notFound(req); }; } diff --git a/packages/0-framework/2-authoring/rpc/src/standard-schema.ts b/packages/0-framework/2-authoring/rpc/src/standard-schema.ts deleted file mode 100644 index 0c34791e..00000000 --- a/packages/0-framework/2-authoring/rpc/src/standard-schema.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Runs a Standard Schema validator over an unknown value, returning the - * parsed output — or throwing with the validator's own issues on failure. - * Shared by the RPC client (validating a response) and serve() (validating a - * request and a handler's return). - */ -import type { StandardSchemaV1 } from '@standard-schema/spec'; - -export async function standardValidate( - schema: S, - value: unknown, -): Promise> { - const result = await schema['~standard'].validate(value); - if (result.issues !== undefined) { - throw new Error( - `Schema validation failed: ${result.issues.map((issue) => issue.message).join('; ')}`, - ); - } - return result.value; -} diff --git a/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test-d.ts b/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test-d.ts index cf01762d..628a4944 100644 --- a/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test-d.ts +++ b/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test-d.ts @@ -10,7 +10,7 @@ import type { ModuleNode } from '@internal/core'; import node from '@internal/node'; import { compute } from '@internal/prisma-cloud'; -import { contract, rpc } from '@internal/rpc'; +import { contract, oc, rpc } from '@internal/rpc'; import { type } from 'arktype'; import { test } from 'vitest'; import { triggerContract } from '../contract.ts'; @@ -20,7 +20,7 @@ import { defineSchedule } from '../schedule.ts'; const build = node({ module: import.meta.url, entry: '../dist/service.mjs' }); const workerContract = contract({ - work: rpc({ input: type({ jobId: 'string' }), output: type({ ok: 'boolean' }) }), + work: oc.input(type({ jobId: 'string' })).output(type({ ok: 'boolean' })), }); const runner = compute({ diff --git a/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test.ts b/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test.ts index 59753057..e9a46515 100644 --- a/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test.ts +++ b/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/module.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { Load, LoadError, module } from '@internal/core'; import node from '@internal/node'; import { compute } from '@internal/prisma-cloud'; -import { contract, rpc } from '@internal/rpc'; +import { contract, oc, rpc } from '@internal/rpc'; import { type } from 'arktype'; import { triggerContract } from '../contract.ts'; import { cron } from '../module.ts'; @@ -11,7 +11,7 @@ import { defineSchedule } from '../schedule.ts'; const build = node({ module: import.meta.url, entry: '../dist/service.mjs' }); const workerContract = contract({ - work: rpc({ input: type({ jobId: 'string' }), output: type({ ok: 'boolean' }) }), + work: oc.input(type({ jobId: 'string' })).output(type({ ok: 'boolean' })), }); const worker = () => diff --git a/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/serve-schedule.test.ts b/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/serve-schedule.test.ts index 403b35f7..f5b4c1df 100644 --- a/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/serve-schedule.test.ts +++ b/packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/serve-schedule.test.ts @@ -53,7 +53,7 @@ function triggerRequest(jobId: string): Request { return new Request('http://cron.internal/rpc/trigger', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ jobId }), + body: JSON.stringify({ json: { jobId } }), }); } @@ -74,7 +74,7 @@ describe('serveSchedule()', () => { const res = await handler(triggerRequest('tick')); expect(res.status).toBe(200); - await expect(res.json()).resolves.toEqual({ ok: true }); + await expect(res.json()).resolves.toEqual({ json: { ok: true } }); expect(tickCalls).toHaveLength(1); expect(mrrCalls).toHaveLength(0); }); diff --git a/packages/1-prisma-cloud/2-shared-modules/cron/src/contract.ts b/packages/1-prisma-cloud/2-shared-modules/cron/src/contract.ts index 2998bfb3..7dacc24d 100644 --- a/packages/1-prisma-cloud/2-shared-modules/cron/src/contract.ts +++ b/packages/1-prisma-cloud/2-shared-modules/cron/src/contract.ts @@ -4,11 +4,11 @@ * (`expose: { trigger: triggerContract }`). `jobId` travels as data through this * single method — adding a job never adds a method, service, or port. */ -import { contract, rpc } from '@internal/rpc'; +import { contract, oc } from '@internal/rpc'; import { type } from 'arktype'; export const triggerContract = contract({ - trigger: rpc({ input: type({ jobId: 'string' }), output: type({ ok: 'boolean' }) }), + trigger: oc.input(type({ jobId: 'string' })).output(type({ ok: 'boolean' })), }); export type TriggerContract = typeof triggerContract; diff --git a/packages/1-prisma-cloud/2-shared-modules/cron/src/serve-schedule.ts b/packages/1-prisma-cloud/2-shared-modules/cron/src/serve-schedule.ts index bbd9a443..406089fa 100644 --- a/packages/1-prisma-cloud/2-shared-modules/cron/src/serve-schedule.ts +++ b/packages/1-prisma-cloud/2-shared-modules/cron/src/serve-schedule.ts @@ -2,15 +2,13 @@ * serveSchedule(service, schedule, handlers) is serve() specialized to the * cron trigger contract: the single exposed `trigger` method dispatches * internally on `jobId` to the schedule's handler map, which `handlers` must - * cover exactly — the same exhaustiveness serve() enforces over a service's - * exposed methods, but sourced from the schedule's job ids instead of the - * contract's methods. + * cover exactly. The native trigger router remains exhaustive over the RPC + * contract; this additional map is sourced from the schedule's job ids. */ import type { Deps, HydratedDeps, Params, RunnableServiceNode } from '@internal/core'; import { blindCast } from '@internal/foundation/casts'; -import type { Handlers } from '@internal/rpc'; -import { serve } from '@internal/rpc'; -import type { TriggerContract } from './contract.ts'; +import { implement, serve } from '@internal/rpc'; +import { type TriggerContract, triggerContract } from './contract.ts'; import type { Schedule } from './schedule.ts'; type ScheduleHandler = (deps: D) => Promise; @@ -24,26 +22,21 @@ export function serveSchedule>, "handlers is the exhaustive typed map keyed by the schedule's Ids; dispatch indexes it by the runtime jobId string" >(handlers); + const deps = service.load(); - const triggerHandler = async ( - input: { jobId: string }, - deps: HydratedDeps, - ): Promise<{ ok: boolean }> => { - const handler = byId[input.jobId]; - if (handler === undefined) { - throw new Error( - `serveSchedule(): no handler for job id "${input.jobId}" — not in the schedule.`, - ); - } - await handler(deps); - return { ok: true }; - }; + const rpc = implement(triggerContract.router); + const router = rpc.router({ + trigger: rpc.trigger.handler(async ({ input }) => { + const handler = byId[input.jobId]; + if (handler === undefined) { + throw new Error( + `serveSchedule(): no handler for job id "${input.jobId}" — not in the schedule.`, + ); + } + await handler(deps); + return { ok: true }; + }), + }); - return serve( - service, - blindCast< - Handlers, - "Handlers can't be verified against S while S stays an unresolved type parameter here (TS can't project a mapped type over an unfixed generic); triggerHandler above is hand-typed to triggerContract's exact input/output shape, and the exhaustiveness guarantee serveSchedule promises callers comes from this function's own `handlers` parameter type, not from this internal call" - >({ trigger: { trigger: triggerHandler } }), - ); + return serve(service, { trigger: router }); } diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 337fd8a4..51dbef42 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -30,6 +30,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@orpc/client": "2.0.0-beta.18", + "@orpc/contract": "2.0.0-beta.18", + "@orpc/server": "2.0.0-beta.18", "@standard-schema/spec": "^1.1.0", "alchemy": "2.0.0-beta.59", "arktype": "^2.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e94ce29..a71a9f9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,31 @@ importers: specifier: ^6.0.3 version: 6.0.3 + examples/hono-rpc: + dependencies: + '@prisma/composer': + specifier: workspace:0.1.0 + version: link:../../packages/9-public/composer + '@prisma/composer-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../packages/9-public/composer-prisma-cloud + arktype: + specifier: ^2.2.3 + version: 2.2.3 + hono: + specifier: ^4.12.30 + version: 4.12.30 + devDependencies: + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + tsdown: + specifier: ^0.22.4 + version: 0.22.4(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + examples/pn-widgets: dependencies: '@prisma-next/postgres': @@ -537,6 +562,15 @@ importers: '@internal/foundation': specifier: workspace:0.1.0 version: link:../../0-foundation/foundation + '@orpc/client': + specifier: 2.0.0-beta.18 + version: 2.0.0-beta.18 + '@orpc/contract': + specifier: 2.0.0-beta.18 + version: 2.0.0-beta.18 + '@orpc/server': + specifier: 2.0.0-beta.18 + version: 2.0.0-beta.18 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -845,6 +879,15 @@ importers: packages/9-public/composer: dependencies: + '@orpc/client': + specifier: 2.0.0-beta.18 + version: 2.0.0-beta.18 + '@orpc/contract': + specifier: 2.0.0-beta.18 + version: 2.0.0-beta.18 + '@orpc/server': + specifier: 2.0.0-beta.18 + version: 2.0.0-beta.18 '@prisma/management-api-sdk': specifier: ^1.47.0 version: 1.47.0 @@ -1978,6 +2021,28 @@ packages: resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} engines: {node: '>= 20'} + '@orpc/client@2.0.0-beta.18': + resolution: {integrity: sha512-PZengUM+bbElAV/ci+cxvpjbeQkxFYj+c72IfktOp5hz0gekq5hkdVPiz/YTsvGxqe4H1yjwRBOmDoeQOTWGNg==} + + '@orpc/contract@2.0.0-beta.18': + resolution: {integrity: sha512-IAA3L5GCtnScsLqapJ+nJkfFK1T4YBJgR+aYbJQJdX4Gu+8Eb1u10kqddurZUWKk7wJgx7yuA0VNfzB0IPlC+g==} + + '@orpc/server@2.0.0-beta.18': + resolution: {integrity: sha512-5GwkBpGYd5ekSFs/PNd5T9VXt02PA1HM1zgOOnIXZvHE2RWHqI4pcZQpnjEPrto70e14xHBg+OTNlbrbt7dyUQ==} + peerDependencies: + crossws: '>=0.3.4' + peerDependenciesMeta: + crossws: + optional: true + + '@orpc/shared@2.0.0-beta.18': + resolution: {integrity: sha512-b7CTKKCmHutKAGKxFu9Gd4T1JEZ0yknLZbpQAbhrphcujkKFSdIBKBr4cDRP+Rif56phJ+2qmfVDbJ6fjj1D8A==} + peerDependencies: + '@opentelemetry/api': '>=1.9.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@oxc-project/types@0.130.0': resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} @@ -2574,6 +2639,21 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standardserver/core@0.5.0': + resolution: {integrity: sha512-c6vIXiJtQNVHm0G8vJCCdZ7G4ObgWj71n89HvKvVR5RnajxEEb+wVcIn10rvrMJmd9sR9RtHR6HCubeCFY214A==} + + '@standardserver/fetch@0.5.0': + resolution: {integrity: sha512-0Sosn0v2Cl2Vhcc1st2FKGxwdK0qPq84K40TtZFAePLGq+Zu6hJZ4VsrwOWfm3S7iZ0EeRo9OByp0oUhm8NiPw==} + + '@standardserver/node@0.5.0': + resolution: {integrity: sha512-4OYFeVyVUZzWOjw9g2qf1lpWTNtjOYTr68w0zOy+AXEq3BiXoQweepoP7WgdYVJ4kmpmOwk1LOrJRqeqPrpTcQ==} + + '@standardserver/peer@0.5.0': + resolution: {integrity: sha512-gsOgvknhj8XirAqJksDZlrzkppGU2WH4PyxzISwfG8Ug3TJyOYwWvNCY81HNe5PJWjDdelIkgHSyjb2apkN2CA==} + + '@standardserver/shared@0.5.0': + resolution: {integrity: sha512-YPkkdqyKQ+6tvtP2pPLzIIrvhjrsvnI006tTTSWqFc2US6X8r1zfYVhfHf9Zpn4HsdhyhwpWwF4fIsN+nlr1Ow==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -3050,6 +3130,10 @@ packages: resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3266,6 +3350,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.12.30: + resolution: {integrity: sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==} + engines: {node: '>=16.9.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -3797,6 +3885,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + radash@12.1.1: + resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} + engines: {node: '>=14.18.0'} + rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} @@ -5426,6 +5518,42 @@ snapshots: '@octokit/request-error': 7.1.0 '@octokit/webhooks-methods': 6.0.0 + '@orpc/client@2.0.0-beta.18': + dependencies: + '@orpc/shared': 2.0.0-beta.18 + '@standardserver/core': 0.5.0 + '@standardserver/fetch': 0.5.0 + '@standardserver/peer': 0.5.0 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/contract@2.0.0-beta.18': + dependencies: + '@orpc/client': 2.0.0-beta.18 + '@orpc/shared': 2.0.0-beta.18 + '@standard-schema/spec': 1.1.0 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/server@2.0.0-beta.18': + dependencies: + '@orpc/client': 2.0.0-beta.18 + '@orpc/contract': 2.0.0-beta.18 + '@orpc/shared': 2.0.0-beta.18 + '@standardserver/core': 0.5.0 + '@standardserver/fetch': 0.5.0 + '@standardserver/node': 0.5.0 + '@standardserver/peer': 0.5.0 + cookie: 2.0.1 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/shared@2.0.0-beta.18': + dependencies: + '@standardserver/shared': 0.5.0 + radash: 12.1.1 + type-fest: 5.7.0 + '@oxc-project/types@0.130.0': {} '@oxc-project/types@0.139.0': {} @@ -6096,6 +6224,28 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standardserver/core@0.5.0': + dependencies: + '@standardserver/shared': 0.5.0 + + '@standardserver/fetch@0.5.0': + dependencies: + '@standardserver/core': 0.5.0 + '@standardserver/shared': 0.5.0 + + '@standardserver/node@0.5.0': + dependencies: + '@standardserver/core': 0.5.0 + '@standardserver/fetch': 0.5.0 + '@standardserver/shared': 0.5.0 + + '@standardserver/peer@0.5.0': + dependencies: + '@standardserver/core': 0.5.0 + '@standardserver/shared': 0.5.0 + + '@standardserver/shared@0.5.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -6583,6 +6733,8 @@ snapshots: convert-to-spaces@2.0.1: {} + cookie@2.0.1: {} + core-util-is@1.0.3: {} csstype@3.2.3: {} @@ -6815,6 +6967,8 @@ snapshots: dependencies: '@types/hast': 3.0.5 + hono@4.12.30: {} + hookable@6.1.1: {} html-void-elements@3.0.0: {} @@ -7349,6 +7503,8 @@ snapshots: queue-microtask@1.2.3: {} + radash@12.1.1: {} + rc9@3.0.1: dependencies: defu: 6.1.7 diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 750d82c0..a31dc440 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -59,11 +59,11 @@ validator types the messages; arktype is the house choice: ```ts // auth/src/contract.ts -import { contract, rpc } from '@prisma/composer/rpc'; +import { contract, oc } from '@prisma/composer/rpc'; import { type } from 'arktype'; export const authContract = contract({ - verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + verify: oc.input(type({ token: 'string' })).output(type({ ok: 'boolean' })), }); ``` @@ -85,14 +85,15 @@ export default compute({ ``` **The server entry** is what your build produces and the platform boots. It -reads its dependencies through `load()` and serves the contract with -`serve()` — the handler map is keyed by the expose port's name and is -exhaustive at compile time: +reads its dependencies through `load()`, implements the native oRPC contract, +and mounts that router with `serve()`. `implement()` makes the router +exhaustive at compile time; `serve()` is keyed by the exposed port name: ```ts // auth/src/server.ts -import { serve } from '@prisma/composer/rpc'; +import { implement, serve } from '@prisma/composer/rpc'; import { SQL } from 'bun'; +import { authContract } from './contract.ts'; import service from './service.ts'; const { db } = service.load(); // { url } — you build your own client @@ -100,11 +101,13 @@ const { port } = service.config(); // params, separate namespace from deps const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); -const handler = serve(service, { - rpc: { - verify: async ({ token }) => ({ ok: token.length > 0 }), - }, +const rpc = implement(authContract.router); +const router = rpc.router({ + verify: rpc.verify.handler(async ({ input }) => ({ + ok: input.token.length > 0, + })), }); +const handler = serve(service, { rpc: router }); export default handler; // Bind all interfaces — Compute routes external HTTP to the VM; a @@ -144,6 +147,32 @@ export default async function Home() { } ``` +Composer exposes oRPC v2's native contract-first API through +`@prisma/composer/rpc`: author procedures with `oc` and implement routers with +`implement()`. Keep imports on Composer's entry point so the oRPC version is +consistent. oRPC owns procedure typing, nesting, middleware, schemas, errors, +the codec, and cancellation; Composer owns topology, hydration, exact contract +identity, service keys, mount policy, and bounded request parsing. + +Production rules: + +- Handler input is the input schema's transformed output. A handler returns + the output schema's input form; the client receives its transformed output. +- Throw `RpcError` from `@prisma/composer/rpc` for an intentional public + failure. Its code and message cross the wire. Ordinary errors are masked as + `INTERNAL_SERVER_ERROR`; never rely on their message reaching a caller. +- Pass `{ signal }` as the second argument to a generated client method to + propagate request cancellation. +- `serve()` limits encoded bodies to 1 MiB by default. Its third argument can + set a positive `maxBodySize` and an explicit mount `prefix`; never infer a + framework's mount path. +- Native nested routers map to `/rpc/`. Full procedure paths + must be unique across the RPC ports exposed by one service. + +For a real framework pattern, use `examples/hono-rpc`: a Hono gateway and RPC +provider are built as separate artifacts, and its integration test boots both +compiled entries and drives the full chain over HTTP. + **Service-to-service calls are authenticated for you.** At deploy the framework mints a distinct, unguessable **service key** per consumer→provider binding: the consumer's client sends it on every call, and `serve()` returns