import type { Context } from "./context.ts"; export interface SchemaLike { parse(input: unknown): T; } export interface OutputSchemaLike { readonly __output: T; parse(input: unknown): unknown; } export type InferEndpointSchema = TSchema extends OutputSchemaLike ? TValue : never; export interface EndpointErrorBody { code: string; message: string; details?: unknown; } export class EndpointError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly details?: unknown, ) { super(message); this.name = "EndpointError"; } } export interface EndpointDefinition { input?: SchemaLike | OutputSchemaLike; output?: SchemaLike | OutputSchemaLike; auth?: "optional" | "required"; /** Permission checked through an installed @wrnexus/authz middleware. */ permission?: string; /** Resource supplied to bound authorization policies. */ resource?: (ctx: Context, input: I) => unknown | Promise; description?: string; tags?: string[]; handler(input: I, ctx: Context): O | Promise; } export interface DefinedEndpoint { readonly definition: EndpointDefinition; (ctx: Context, input?: unknown): Promise; } function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" }, }); } function schemaValue(schema: SchemaLike | OutputSchemaLike, input: unknown): T { const parsed = schema.parse(input); if ( parsed && typeof parsed === "object" && "ok" in parsed && "value" in parsed && typeof (parsed as { ok?: unknown }).ok === "boolean" ) { const result = parsed as { ok: boolean; value: T; errors?: unknown }; if (!result.ok) throw new EndpointError( 400, "VALIDATION_ERROR", "Endpoint validation failed.", result.errors, ); return result.value; } return parsed as T; } /** Define a validated, typed endpoint that can also drive SDK/OpenAPI generation. */ export function defineEndpoint< InputSchema extends OutputSchemaLike, OutputSchema extends OutputSchemaLike, >( definition: Omit< EndpointDefinition, InferEndpointSchema>, "input" | "output" > & { input: InputSchema; output: OutputSchema; }, ): DefinedEndpoint, InferEndpointSchema>; export function defineEndpoint( definition: EndpointDefinition, ): DefinedEndpoint; export function defineEndpoint( definition: EndpointDefinition, ): DefinedEndpoint { const endpoint = async (ctx: Context, rawInput?: unknown): Promise => { try { if (definition.auth === "required" && !ctx.user) { throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required."); } // The real HTTP router invokes route handlers as `handler(ctx)` — it never // supplies a second argument. Callers that already have a parsed payload // (unit tests, internal RPC-style calls) may still pass one explicitly, and // that always wins. Otherwise, read the request ourselves: query params for // GET/HEAD, JSON body for everything else. let input: unknown = rawInput; if (definition.input) { const resolvedInput = rawInput !== undefined ? rawInput : ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD" ? Object.fromEntries(ctx.url.searchParams) : await ctx.req.json().catch(() => ({})); input = schemaValue(definition.input, resolvedInput); } if (definition.permission) { const authz = ( ctx as Context & { authz?: { decide( permission: string, resource?: unknown, ): Promise<{ allowed: boolean; reason?: string }>; }; } ).authz; if (!authz) { throw new EndpointError( 500, "AUTHZ_NOT_CONFIGURED", "Authorization middleware is not configured.", ); } const resource = definition.resource ? await definition.resource(ctx, input) : undefined; const decision = await authz.decide(definition.permission, resource); if (!decision.allowed) { throw new EndpointError(403, "FORBIDDEN", "Permission denied."); } ctx.resource = resource; } const rawOutput = await definition.handler(input, ctx); const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput; return output instanceof Response ? output : json({ data: output }); } catch (error) { if (error instanceof EndpointError) { return json( { error: { code: error.code, message: error.message, details: error.details } }, error.status, ); } return json( { error: { code: "INTERNAL_ERROR", message: "The endpoint failed unexpectedly.", } satisfies EndpointErrorBody, }, 500, ); } }; return Object.assign(endpoint, { definition }); } export interface RpcClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit | (() => HeadersInit | Promise); } /** Create a tiny typed RPC caller for endpoints exposed by a WrNexus app. */ export function createRpcClient(options: RpcClientOptions = {}) { const request = options.fetch ?? globalThis.fetch; return async function call(path: string, input: I): Promise { const headers = typeof options.headers === "function" ? await options.headers() : (options.headers ?? {}); const response = await request(new URL(path, options.baseUrl ?? globalThis.location?.origin), { method: "POST", headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) }, body: JSON.stringify(input), }); const body = (await response.json()) as { data?: O; error?: EndpointErrorBody }; if (!response.ok || body.error) { throw new EndpointError( response.status, body.error?.code ?? "RPC_ERROR", body.error?.message ?? `RPC request failed with ${response.status}`, body.error?.details, ); } return body.data as O; }; }