228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
export interface RpcParameterContract {
|
|
name: string;
|
|
type: string;
|
|
optional: boolean;
|
|
}
|
|
export interface RpcManifestContract {
|
|
function: string;
|
|
parameters?: RpcParameterContract[];
|
|
returnType?: string;
|
|
}
|
|
export interface RpcRequestPayload {
|
|
component: string;
|
|
function: string;
|
|
args: unknown[];
|
|
}
|
|
export interface RpcContext {
|
|
request: Request;
|
|
user?: unknown;
|
|
traceId: string;
|
|
}
|
|
export interface RpcHandlerOptions {
|
|
resolve(component: string): Promise<{
|
|
functions: Record<string, (...args: any[]) => any>;
|
|
manifest?: RpcManifestContract[];
|
|
} | null>;
|
|
authenticate?: (request: Request) => Promise<unknown> | unknown;
|
|
authorize?: (context: RpcContext, payload: RpcRequestPayload) => Promise<boolean> | boolean;
|
|
validateCsrf?: (request: Request) => Promise<boolean> | boolean;
|
|
validateInput?: (
|
|
payload: RpcRequestPayload,
|
|
manifestEntry: RpcManifestContract,
|
|
) => Promise<unknown[]> | unknown[];
|
|
validateOutput?: (
|
|
value: unknown,
|
|
manifestEntry: RpcManifestContract,
|
|
) => Promise<unknown> | unknown;
|
|
}
|
|
|
|
function json(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"cache-control": "no-store",
|
|
},
|
|
});
|
|
}
|
|
function error(
|
|
code: string,
|
|
message: string,
|
|
status: number,
|
|
traceId: string,
|
|
details?: unknown,
|
|
): Response {
|
|
return json(
|
|
{ ok: false, error: { code, message, traceId, ...(details === undefined ? {} : { details }) } },
|
|
status,
|
|
);
|
|
}
|
|
function validName(value: unknown): value is string {
|
|
return typeof value === "string" && /^[A-Za-z_$][\w$]*$/.test(value);
|
|
}
|
|
|
|
function removePromise(type: string): string {
|
|
const match = /^Promise\s*<([\s\S]+)>$/.exec(type.trim());
|
|
return match?.[1]?.trim() ?? type.trim();
|
|
}
|
|
|
|
function splitUnion(type: string): string[] {
|
|
const parts: string[] = [];
|
|
let depth = 0;
|
|
let quote = "";
|
|
let start = 0;
|
|
for (let index = 0; index < type.length; index++) {
|
|
const char = type[index]!;
|
|
if (quote) {
|
|
if (char === "\\") index++;
|
|
else if (char === quote) quote = "";
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") quote = char;
|
|
else if ("<([{".includes(char)) depth++;
|
|
else if (">)]}".includes(char)) depth = Math.max(0, depth - 1);
|
|
else if (char === "|" && depth === 0) {
|
|
parts.push(type.slice(start, index).trim());
|
|
start = index + 1;
|
|
}
|
|
}
|
|
parts.push(type.slice(start).trim());
|
|
return parts.filter(Boolean);
|
|
}
|
|
|
|
function matchesRuntimeType(value: unknown, rawType: string): boolean {
|
|
const type = removePromise(rawType).trim();
|
|
if (!type || ["unknown", "any", "never"].includes(type)) return true;
|
|
const union = splitUnion(type);
|
|
if (union.length > 1) return union.some((part) => matchesRuntimeType(value, part));
|
|
if (type === "undefined" || type === "void") return value === undefined;
|
|
if (type === "null") return value === null;
|
|
if (/^"[\s\S]*"$|^'[\s\S]*'$/.test(type)) return value === type.slice(1, -1);
|
|
if (/^-?\d+(?:\.\d+)?$/.test(type)) return value === Number(type);
|
|
if (type === "true") return value === true;
|
|
if (type === "false") return value === false;
|
|
if (type === "string") return typeof value === "string";
|
|
if (type === "number") return typeof value === "number" && Number.isFinite(value);
|
|
if (type === "boolean") return typeof value === "boolean";
|
|
if (type === "bigint") return typeof value === "bigint";
|
|
if (type === "Date")
|
|
return value instanceof Date || (typeof value === "string" && !Number.isNaN(Date.parse(value)));
|
|
if (type.endsWith("[]"))
|
|
return (
|
|
Array.isArray(value) && value.every((item) => matchesRuntimeType(item, type.slice(0, -2)))
|
|
);
|
|
if (/^(?:Readonly)?Array\s*</.test(type)) return Array.isArray(value);
|
|
if (/^(?:Record|Map|Set)\s*</.test(type)) return value !== null && typeof value === "object";
|
|
if (/^\{[\s\S]*\}$/.test(type))
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
// Named interfaces/types are validated by generated application validators
|
|
// when available. Their JSON transport shape must at least be object-like.
|
|
if (/^[A-Za-z_$][\w$]*(?:<.*>)?$/.test(type)) return value !== null && typeof value === "object";
|
|
return true;
|
|
}
|
|
|
|
function validateArguments(payload: RpcRequestPayload, manifest: RpcManifestContract): unknown[] {
|
|
const parameters = manifest.parameters ?? [];
|
|
const required = parameters.filter((parameter) => !parameter.optional).length;
|
|
if (payload.args.length < required || payload.args.length > parameters.length) {
|
|
throw new TypeError(
|
|
`WRN-RPC-INPUT: ${manifest.function} expects ${required === parameters.length ? required : `${required}-${parameters.length}`} arguments, received ${payload.args.length}.`,
|
|
);
|
|
}
|
|
parameters.forEach((parameter, index) => {
|
|
const value = payload.args[index];
|
|
if (value === undefined && parameter.optional) return;
|
|
if (!matchesRuntimeType(value, parameter.type)) {
|
|
throw new TypeError(
|
|
`WRN-RPC-INPUT: argument '${parameter.name}' expected ${parameter.type}.`,
|
|
);
|
|
}
|
|
});
|
|
return payload.args;
|
|
}
|
|
|
|
function validateReturn(value: unknown, manifest: RpcManifestContract): unknown {
|
|
const type = manifest.returnType ?? "unknown";
|
|
if (!matchesRuntimeType(value, type)) {
|
|
throw new TypeError(
|
|
`WRN-RPC-OUTPUT: ${manifest.function} returned a value incompatible with ${type}.`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function createRpcHandler(
|
|
options: RpcHandlerOptions,
|
|
): (request: Request) => Promise<Response> {
|
|
return async (request) => {
|
|
const traceId = request.headers.get("x-request-id") || crypto.randomUUID();
|
|
if (request.method !== "POST")
|
|
return error("WRN-RPC-METHOD", "RPC requires POST", 405, traceId);
|
|
if (options.validateCsrf && !(await options.validateCsrf(request))) {
|
|
return error("WRN-RPC-CSRF", "CSRF validation failed", 403, traceId);
|
|
}
|
|
let payload: RpcRequestPayload;
|
|
try {
|
|
payload = (await request.json()) as RpcRequestPayload;
|
|
} catch {
|
|
return error("WRN-RPC-JSON", "Invalid JSON request", 400, traceId);
|
|
}
|
|
if (
|
|
!validName(payload.component) ||
|
|
!validName(payload.function) ||
|
|
!Array.isArray(payload.args)
|
|
) {
|
|
return error("WRN-RPC-PAYLOAD", "Invalid RPC payload", 400, traceId);
|
|
}
|
|
const resolved = await options.resolve(payload.component);
|
|
if (!resolved)
|
|
return error(
|
|
"WRN-RPC-COMPONENT",
|
|
`Unknown RPC component '${payload.component}'`,
|
|
404,
|
|
traceId,
|
|
);
|
|
const manifestEntry = resolved.manifest?.find((entry) => entry.function === payload.function);
|
|
if (!manifestEntry) {
|
|
return error(
|
|
"WRN-RPC-FUNCTION",
|
|
`Server function '${payload.function}' is not remotely exposed`,
|
|
404,
|
|
traceId,
|
|
);
|
|
}
|
|
const fn = resolved.functions[payload.function];
|
|
if (typeof fn !== "function") {
|
|
return error(
|
|
"WRN-RPC-FUNCTION",
|
|
`Server function '${payload.function}' is not available`,
|
|
404,
|
|
traceId,
|
|
);
|
|
}
|
|
const user = options.authenticate ? await options.authenticate(request) : undefined;
|
|
const context: RpcContext = { request, user, traceId };
|
|
if (options.authorize && !(await options.authorize(context, payload))) {
|
|
return error("WRN-RPC-AUTHZ", "Not authorized", 403, traceId);
|
|
}
|
|
try {
|
|
const args = options.validateInput
|
|
? await options.validateInput(payload, manifestEntry)
|
|
: validateArguments(payload, manifestEntry);
|
|
const value = await fn(...args, context);
|
|
const validated = options.validateOutput
|
|
? await options.validateOutput(value, manifestEntry)
|
|
: validateReturn(value, manifestEntry);
|
|
return json({ ok: true, value: validated, traceId });
|
|
} catch (cause) {
|
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
const code = message.startsWith("WRN-RPC-INPUT")
|
|
? "WRN-RPC-INPUT"
|
|
: message.startsWith("WRN-RPC-OUTPUT")
|
|
? "WRN-RPC-OUTPUT"
|
|
: "WRN-RPC-EXECUTION";
|
|
return error(code, message, code === "WRN-RPC-EXECUTION" ? 500 : 400, traceId);
|
|
}
|
|
};
|
|
}
|