diff --git a/bun.lock b/bun.lock index 4bc2af59..98b8fcd9 100644 --- a/bun.lock +++ b/bun.lock @@ -444,6 +444,20 @@ "@wrnexus/core": "workspace:*", }, }, + "packages/rpc": { + "name": "@wrnexus/rpc", + "version": "0.8.4", + "dependencies": { + "@wrnexus/authz": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/jwt": "workspace:*", + "@wrnexus/validation": "workspace:*", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2", + }, + }, "packages/security": { "name": "@wrnexus/security", "version": "0.8.4", @@ -887,6 +901,8 @@ "@wrnexus/router": ["@wrnexus/router@workspace:packages/router"], + "@wrnexus/rpc": ["@wrnexus/rpc@workspace:packages/rpc"], + "@wrnexus/security": ["@wrnexus/security@workspace:packages/security"], "@wrnexus/ssr": ["@wrnexus/ssr@workspace:packages/ssr"], diff --git a/packages/rpc/package.json b/packages/rpc/package.json new file mode 100644 index 00000000..b1832c35 --- /dev/null +++ b/packages/rpc/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wrnexus/rpc", + "version": "0.8.4", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "description": "Typed request/response calls between workspace apps, carrying end-user identity.", + "files": [ + "src", + "README.md" + ], + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "check": "bun run typecheck && bun run test" + }, + "dependencies": { + "@wrnexus/authz": "workspace:*", + "@wrnexus/core": "workspace:*", + "@wrnexus/jwt": "workspace:*", + "@wrnexus/validation": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "^5.9.2" + } +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts new file mode 100644 index 00000000..f2292072 --- /dev/null +++ b/packages/rpc/src/index.ts @@ -0,0 +1,19 @@ +/** + * @wrnexus/rpc — typed request/response between workspace apps. + * + * A contract lives in the workspace's shared package and is imported by both + * sides: the callee `implement`s it, the caller gets a typed proxy. Types flow + * through a normal import, so there is no code generator and no generated file + * to go stale. + */ + +export type { + AnyProcedures, + InferInput, + InputSchema, + InferProcedureInput, + InferProcedureOutput, + ProcedureDef, + ServiceContract, + ServiceResult, +} from "./types.ts"; diff --git a/packages/rpc/src/types.ts b/packages/rpc/src/types.ts new file mode 100644 index 00000000..e925438e --- /dev/null +++ b/packages/rpc/src/types.ts @@ -0,0 +1,49 @@ +import type { ObjectSchema } from "@wrnexus/validation"; + +/** Extract the validated value type from a `v.object(...)` schema. */ +export type InferInput = S extends ObjectSchema ? T : never; + +/** + * One callable procedure on a service. `input` is validated on the callee + * before the handler runs; `permission` is enforced there too. + */ +/** Structural shape of a validation schema, so ProcedureDef needs no generic. */ +export interface InputSchema { + parse(value: Record): { + ok: boolean; + value: unknown; + errors: Record; + }; +} + +export interface ProcedureDef { + input?: InputSchema; + /** Permission the callee checks before invoking the handler. */ + permission?: string; + /** Only idempotent procedures are ever retried. */ + idempotent?: boolean; + /** Type-only markers; never present at runtime. */ + readonly __input?: Input; + readonly __output?: Output; +} + +/** + * A procedure map with its element types erased. The `any` is deliberate and + * confined to this alias: the phantom `__input`/`__output` markers make + * ProcedureDef invariant, so no narrower erasure accepts a real contract. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyProcedures = Record>; + +export interface ServiceContract { + /** Stable service id, used in the mounted path. */ + name: string; + procedures: Procedures; +} + +export type InferProcedureInput

= P extends ProcedureDef ? I : never; +export type InferProcedureOutput

= P extends ProcedureDef ? O : never; + +/** What a transport returns: either a value or a structured failure. */ +export type ServiceResult = + { ok: true; value: T } | { ok: false; code: string; message: string; retryable: boolean }; diff --git a/packages/rpc/test/types.test.ts b/packages/rpc/test/types.test.ts new file mode 100644 index 00000000..ba6c801b --- /dev/null +++ b/packages/rpc/test/types.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { v } from "@wrnexus/validation"; +import type { InferInput, ProcedureDef, ServiceContract } from "../src/types.ts"; + +describe("rpc types", () => { + test("InferInput extracts the validated shape from a schema", () => { + const schema = v.object({ userId: v.string(), amountCents: v.number() }); + // Compile-time assertion: assigning a correctly-shaped value must typecheck. + const value: InferInput = { userId: "u1", amountCents: 10 }; + expect(value.userId).toBe("u1"); + }); + + test("a contract carries its procedure map", () => { + const contract: ServiceContract<{ ping: ProcedureDef }> = { + name: "demo", + procedures: { ping: {} }, + }; + expect(contract.name).toBe("demo"); + expect(Object.keys(contract.procedures)).toEqual(["ping"]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5f00913d..d902e4b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -75,6 +75,7 @@ "@wrnexus/ui/component-reference.json": ["./packages/ui/component-reference.json"], "@wrnexus/ui/ui.css": ["./packages/ui/ui.css"], "@wrnexus/validation": ["./packages/validation/src/index.ts"], + "@wrnexus/rpc": ["./packages/rpc/src/index.ts"], "@wrnexus/ai/*": ["./packages/ai/src/*.ts"], "@wrnexus/authz/*": ["./packages/authz/src/*.ts"], "@wrnexus/cli/*": ["./packages/cli/src/*.ts"],