Files
WRNexusJS/packages/core/src/endpoint.ts
T
ClintchizandClaude Opus 5 5318320c70 fix(examples): make the api-block demo endpoint actually type-check
directory.ts previously exported a plain (ctx: Context) => ... handler.
With that shape ApiInput<> resolved to unknown, so the generated
__wrn_api_check assertion for the demo page passed trivially even with
a field the endpoint does not accept -- the worked example did not
demonstrate the type safety it exists to demonstrate.

Rewrite directory.ts to use defineEndpoint with a schema (matching
typed-user.ts), which gives the generated assertion a real input type
to check against. Confirmed: adding an unaccepted field to the block's
request body now fails typecheck naming
__wrn_api_check_searchDirectory; removing it passes with zero net
diff.

Fix a real bug this surfaced: packages/core/src/endpoint.ts only read
its input from a second 'rawInput' argument, but the actual HTTP
router (packages/dev-server/src/runtime.ts handleApi) invokes route
handlers as handler(ctx) with no second argument. Every
defineEndpoint-based route -- including the pre-existing typed-user.ts
example -- silently received an empty/undefined input through the
real router (confirmed via curl: valid typed-user payloads were
rejected as 'Required'; directory's name filter matched every record
regardless of query). Fixed by having the endpoint wrapper parse the
request itself (query params for GET/HEAD, JSON body otherwise) when
no rawInput is explicitly supplied, while still honoring an explicit
rawInput for direct/unit-test callers.

Also update api-block-demo.wrn's response section: defineEndpoint
wraps handler output as { data: ... }, so the block's raw response
body is now { data: { users: [...] } } -- response reads
data.data.users instead of data.users.

Re-verified in a real browser after the endpoint rewrite and the
router fix: search returns exactly "Ajay, Asha", exactly one
POST /api/directory carrying x-csrf-token, and the error section
still runs cleanly (no exception, empty result) on a missing route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:05:10 +05:30

164 lines
5.4 KiB
TypeScript

import type { Context } from "./context.ts";
export interface SchemaLike<T> {
parse(input: unknown): T;
}
export interface OutputSchemaLike<T> {
readonly __output: T;
parse(input: unknown): unknown;
}
export type InferEndpointSchema<TSchema> =
TSchema extends OutputSchemaLike<infer TValue> ? 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<I, O> {
input?: SchemaLike<I> | OutputSchemaLike<I>;
output?: SchemaLike<O> | OutputSchemaLike<O>;
auth?: "optional" | "required";
description?: string;
tags?: string[];
handler(input: I, ctx: Context): O | Promise<O>;
}
export interface DefinedEndpoint<I, O> {
readonly definition: EndpointDefinition<I, O>;
(ctx: Context, input?: unknown): Promise<Response>;
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
function schemaValue<T>(schema: SchemaLike<T> | OutputSchemaLike<T>, 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<unknown>,
OutputSchema extends OutputSchemaLike<unknown>,
>(
definition: Omit<
EndpointDefinition<InferEndpointSchema<InputSchema>, InferEndpointSchema<OutputSchema>>,
"input" | "output"
> & {
input: InputSchema;
output: OutputSchema;
},
): DefinedEndpoint<InferEndpointSchema<InputSchema>, InferEndpointSchema<OutputSchema>>;
export function defineEndpoint<I = unknown, O = unknown>(
definition: EndpointDefinition<I, O>,
): DefinedEndpoint<I, O>;
export function defineEndpoint(
definition: EndpointDefinition<unknown, unknown>,
): DefinedEndpoint<unknown, unknown> {
const endpoint = async (ctx: Context, rawInput?: unknown): Promise<Response> => {
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.
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(() => ({}));
const input = definition.input ? schemaValue(definition.input, resolvedInput) : resolvedInput;
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<HeadersInit>);
}
/** 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<I, O>(path: string, input: I): Promise<O> {
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;
};
}