release: WRNexusJS 0.6.0
This commit is contained in:
@@ -422,3 +422,6 @@ export function streamDocumentResponse(
|
||||
if (!headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
|
||||
return new Response(renderDocumentStream(opts), { ...init, headers });
|
||||
}
|
||||
|
||||
export * from "./rpc.ts";
|
||||
export * from "./store-context.ts";
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRequestStoreContainer } from "@wrnexus/store/server";
|
||||
import type { StoreContainer } from "@wrnexus/store";
|
||||
|
||||
const containers = new WeakMap<object, StoreContainer>();
|
||||
|
||||
export function requestStoreContainer(request: object, routeId?: string): StoreContainer {
|
||||
const existing = containers.get(request);
|
||||
if (existing) return existing;
|
||||
const created: StoreContainer = createRequestStoreContainer(request, routeId);
|
||||
containers.set(request, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function disposeRequestStores(request: object): Promise<void> {
|
||||
const container = containers.get(request);
|
||||
if (!container) return;
|
||||
containers.delete(request);
|
||||
await container.dispose();
|
||||
}
|
||||
|
||||
export function renderStoreHydration(container: StoreContainer, nonce?: string): string {
|
||||
const json = JSON.stringify(container.serialize()).replace(/</g, "\\u003c");
|
||||
return `<script type="application/json" data-wrnexus-store-hydration${nonce ? ` nonce="${nonce}"` : ""}>${json}</script>`;
|
||||
}
|
||||
Reference in New Issue
Block a user