Skip to content
This repository was archived by the owner on Mar 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/rpc-method-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"servicexjs": minor
"@servicexjs/core": minor
"@servicexjs/node": minor
---

feat: RPC method metadata and /rpc/methods discovery endpoint

- Add `RpcMethodDefinition` with `description` and `permissions` fields
- Replace `publicMethods()` with inline `permissions: []` on method definitions
- Add `/rpc/methods` GET endpoint for method discovery (replaces `/schema`)
- Rename built-in RPC method from `rpc.describe` to `rpc.methods`
- `ServiceDefinition.methods` now uses `NormalizedRpcMethods` (always full definitions)

BREAKING CHANGE: `publicMethods()` builder method removed. Use `permissions: []` in method definition instead.
7 changes: 3 additions & 4 deletions bdd/steps/service-builder.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,8 @@ When(

When("running with the node runtime", function (this: ServiceXWorld) {
const methods = (this as any)._methods || {};
const publicMethods = (this as any)._publicMethods || [];
this.runResult = createService((this as any)._serviceName || "test")
.rpc(methods)
.publicMethods(publicMethods)
.run(node({ port: 0 }));
});

Expand Down Expand Up @@ -82,8 +80,9 @@ When(
"creating service {string} with {string} as a public method",
function (this: ServiceXWorld, name: string, method: string) {
(this as any)._serviceName = name;
(this as any)._methods = { [method]: async () => ({ status: "ok" }) };
(this as any)._publicMethods = [method];
(this as any)._methods = {
[method]: { handler: async () => ({ status: "ok" }), permissions: [] },
};
}
);

Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/container/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RpcMethods } from "../rpc";
import type { NormalizedRpcMethods } from "../rpc";

/**
* Registration context for declaring dependencies.
Expand All @@ -20,12 +20,13 @@ export type RegisterFn = (ctx: RegistrationContext, env: Record<string, unknown>
/**
* The complete, platform-agnostic definition of a service.
* Built via the fluent API in `servicexjs`, consumed by Runtime adapters.
*
* Methods are always normalized: each entry is a full `RpcMethodDefinition`.
*/
export interface ServiceDefinition {
name: string;
methods: RpcMethods;
methods: NormalizedRpcMethods;
registerFn: RegisterFn | null;
publicMethods: string[];
}

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,25 @@ export { DrizzleRepository } from "./repository";
export type {
AuthContext,
MethodSchema,
NormalizedRpcMethods,
RpcContext,
RpcError,
RpcErrorResponse,
RpcMethodDefinition,
RpcMethodEntry,
RpcMethodHandler,
RpcMethods,
RpcRequest,
RpcResponse,
RpcSuccessResponse,
ServiceSchema,
} from "./rpc";
// RPC constants
// RPC constants & helpers
export {
DOMAIN_ERROR_CODE_MAP,
ERROR_HTTP_STATUS_MAP,
ErrorCodes,
isMethodDefinition,
JSONRPC_VERSION,
normalizeMethod,
} from "./rpc";
52 changes: 47 additions & 5 deletions packages/core/src/rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,50 @@ export type RpcMethodHandler<TParams = any, TResult = any> = (
) => Promise<TResult> | TResult;

/**
* Map of method names to their handlers.
* RPC method definition with metadata.
*
* - `permissions: undefined` → requires authentication (default)
* - `permissions: []` → public, no auth needed
* - `permissions: ["admin"]` → requires auth + specific permission
*/
export interface RpcMethodDefinition {
handler: RpcMethodHandler;
description?: string;
permissions?: string[];
}

/**
* A method entry: either a bare handler or a full definition with metadata.
*/
export type RpcMethods = Record<string, RpcMethodHandler>;
export type RpcMethodEntry = RpcMethodHandler | RpcMethodDefinition;

/**
* Map of method names to their handlers or definitions.
*/
export type RpcMethods = Record<string, RpcMethodEntry>;

/**
* Normalized method map — all entries resolved to full definitions.
* Used internally by ServiceDefinition and runtime adapters.
*/
export type NormalizedRpcMethods = Record<string, RpcMethodDefinition>;

/**
* Check if a method entry is a full definition (not a bare handler).
*/
export function isMethodDefinition(entry: RpcMethodEntry): entry is RpcMethodDefinition {
return typeof entry === "object" && entry !== null && "handler" in entry;
}

/**
* Normalize a method entry to a full definition.
*/
export function normalizeMethod(entry: RpcMethodEntry): RpcMethodDefinition {
if (isMethodDefinition(entry)) {
return entry;
}
return { handler: entry };
}

// ==================== JSON-RPC 2.0 Request/Response ====================

Expand Down Expand Up @@ -154,15 +195,16 @@ export type RpcResponse<T = unknown> = RpcSuccessResponse<T> | RpcErrorResponse;
// ==================== Schema ====================

/**
* Method schema for rpc.describe.
* Method schema for /rpc/methods.
*/
export interface MethodSchema {
name: string;
public: boolean;
description?: string;
permissions?: string[];
}

/**
* Service schema returned by rpc.describe.
* Service schema returned by /rpc/methods.
*/
export interface ServiceSchema {
name: string;
Expand Down
60 changes: 52 additions & 8 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { AuthContext, RpcContext, Runtime, ServiceDefinition } from "@servicexjs/core";
import type {
AuthContext,
RpcContext,
RpcMethodDefinition,
Runtime,
ServiceDefinition,
ServiceSchema,
} from "@servicexjs/core";
import { ServiceContainerImpl } from "@servicexjs/core";
import { Hono } from "hono";
import { cors } from "hono/cors";
Expand Down Expand Up @@ -31,6 +38,29 @@ const ERROR_STATUS_MAP: Record<string, number> = {
CONFLICT: 409,
};

/**
* Check if a method is public (no auth required).
*/
function isPublicMethod(method: RpcMethodDefinition): boolean {
return Array.isArray(method.permissions) && method.permissions.length === 0;
}

/**
* Build schema from service definition.
*/
function buildSchema(definition: ServiceDefinition): ServiceSchema {
const methods = Object.entries(definition.methods).map(([name, def]) => ({
name,
...(def.description && { description: def.description }),
...(def.permissions && { permissions: def.permissions }),
}));

return {
name: definition.name,
methods,
};
}

/**
* Create a Node.js runtime adapter for development and testing.
*
Expand All @@ -40,7 +70,12 @@ const ERROR_STATUS_MAP: Record<string, number> = {
* import { node } from "@servicexjs/node";
*
* export default createService("tenant")
* .rpc({ ... })
* .rpc({
* "tenant.create": {
* handler: async (params, ctx) => { ... },
* description: "Create a tenant",
* },
* })
* .register((ctx, env) => { ... })
* .run(node({
* port: 3000,
Expand All @@ -62,6 +97,11 @@ export function node(config: NodeConfig = {}): Runtime<{ app: Hono; port: number
const app = new Hono().basePath(basePath);
app.use("*", cors());

// Method discovery endpoint
app.get("/rpc/methods", (c) => {
return c.json(buildSchema(definition));
});

// RPC endpoint
app.post("/rpc", async (c) => {
container.initialize(definition.registerFn, env);
Expand All @@ -81,19 +121,23 @@ export function node(config: NodeConfig = {}): Runtime<{ app: Hono; port: number
);
}

const handler = definition.methods[method];
if (!handler) {
// Built-in: rpc.methods
if (method === "rpc.methods") {
return c.json({ result: buildSchema(definition) });
}

const methodDef = definition.methods[method];
if (!methodDef) {
return c.json(
{ error: { code: "METHOD_NOT_FOUND", message: `Unknown method: ${method}` } },
404
);
}

// Auth
const isPublic = definition.publicMethods.includes(method);
let auth: AuthContext | null = null;

if (!isPublic) {
if (!isPublicMethod(methodDef)) {
const token = extractToken(c, cookieName);
if (!token) {
return c.json({ error: { code: "UNAUTHORIZED", message: "No token provided" } }, 401);
Expand All @@ -116,7 +160,7 @@ export function node(config: NodeConfig = {}): Runtime<{ app: Hono; port: number
};

try {
const result = await handler(params, ctx);
const result = await methodDef.handler(params, ctx);
return c.json({ result });
} catch (err) {
if (err instanceof Error && "code" in err && typeof (err as any).code === "string") {
Expand All @@ -136,7 +180,7 @@ export function node(config: NodeConfig = {}): Runtime<{ app: Hono; port: number

// ---- Internal helpers ----

function extractToken(c: any, cookieName: string): string | null {
function extractToken(c: any, _cookieName: string): string | null {
const authHeader = c.req.header("Authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice(7);
Expand Down
Loading
Loading