Expands 3.1 and 3.2 so the work can be done without re-deriving anything. 3.1 now records what 0.8.6 already fixed, separated into the ten components that were miswired and the five that gained outputs they had been firing undeclared, with the caveat that Map's three were converted but never confirmed in a browser. For the 22 that remain it adds the finding that changes the decision: all nine are pure scaffolds with no state, functions or handlers, and five of them duplicate a component that already works -- FileUpload against FileInput and FileUploadProgress, Toast and ToastNotifications against Toaster, AdvancedDatePicker against DatePicker, AdvancedRangeSlider against RangeSlider. Superseding those is a migration entry rather than new code, and leaves Chart, TreeView, Confetti and CopyMarkup as the only ones needing to be built. 3.2 corrects the scaffold count from 23 to 28; the earlier figure used a looser rule. Nine of the 28 are the 3.1 components, so the two items must be planned together, and several of the rest are primitives that need only their styles moved out of ui.css rather than any behaviour. Also corrects the dead-output component count from 11 to 9 in both documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
4.9 KiB
TypeScript
138 lines
4.9 KiB
TypeScript
import {
|
|
RPC_IDENTITY_HEADER,
|
|
RPC_INTERNAL_HEADER,
|
|
RPC_PATH_PREFIX,
|
|
RPC_STREAM_PATH_PREFIX,
|
|
type ServiceImplementation,
|
|
type StreamImplementation,
|
|
} from "@wrnexus/rpc";
|
|
|
|
export { RPC_INTERNAL_HEADER };
|
|
const EDGE_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded"];
|
|
|
|
export function isRpcPath(pathname: string): boolean {
|
|
return (
|
|
pathname === RPC_PATH_PREFIX ||
|
|
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
|
pathname === RPC_STREAM_PATH_PREFIX ||
|
|
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
|
);
|
|
}
|
|
|
|
export function isInternalCaller(req: Request): boolean {
|
|
return (
|
|
req.headers.get(RPC_INTERNAL_HEADER) === "1" &&
|
|
!EDGE_HEADERS.some((name) => req.headers.has(name))
|
|
);
|
|
}
|
|
|
|
function json(body: unknown, status = 200): Response {
|
|
return Response.json(body, { status, headers: { "cache-control": "private, no-store" } });
|
|
}
|
|
|
|
export async function handleRpcRequest(
|
|
req: Request,
|
|
url: URL,
|
|
services: Map<string, ServiceImplementation | StreamImplementation>,
|
|
): Promise<Response | null> {
|
|
if (!isRpcPath(url.pathname)) return null;
|
|
if (!isInternalCaller(req)) return new Response("Not found", { status: 404 });
|
|
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
|
const streaming =
|
|
url.pathname === RPC_STREAM_PATH_PREFIX ||
|
|
url.pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`);
|
|
const segments = url.pathname.split("/");
|
|
const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
const serviceName = segments[3];
|
|
const procedure = segments[4];
|
|
const service =
|
|
serviceName && SAFE_SEGMENT.test(serviceName) ? services.get(serviceName) : undefined;
|
|
if (!service || !procedure || !SAFE_SEGMENT.test(procedure) || segments.length !== 5) {
|
|
return json({ ok: false, code: "RPC_UNKNOWN", message: "Unknown procedure", retryable: false });
|
|
}
|
|
let payload: unknown;
|
|
try {
|
|
payload = await req.json();
|
|
} catch {
|
|
return json({ ok: false, code: "RPC_INVALID", message: "Invalid input", retryable: false });
|
|
}
|
|
if (streaming) {
|
|
if (!("stream" in service) || typeof service.stream !== "function") {
|
|
return json({
|
|
ok: false,
|
|
code: "RPC_UNKNOWN",
|
|
message: "Unknown procedure",
|
|
retryable: false,
|
|
});
|
|
}
|
|
const encoder = new TextEncoder();
|
|
const identity = req.headers.get(RPC_IDENTITY_HEADER) ?? undefined;
|
|
const iterator = service.stream(procedure, payload, identity)[Symbol.asyncIterator]();
|
|
const { maxFrameBytes, heartbeatMs } = service.streamOptions;
|
|
service.metrics.begin();
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
try {
|
|
let next = iterator.next();
|
|
while (true) {
|
|
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const heartbeat = new Promise<{ kind: "heartbeat" }>((resolve) => {
|
|
heartbeatTimer = setTimeout(() => resolve({ kind: "heartbeat" }), heartbeatMs);
|
|
});
|
|
const outcome = await Promise.race([
|
|
next.then((value) => ({ kind: "data" as const, value })),
|
|
heartbeat,
|
|
]);
|
|
if (heartbeatTimer !== undefined) clearTimeout(heartbeatTimer);
|
|
if (outcome.kind === "heartbeat") {
|
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
continue;
|
|
}
|
|
if (outcome.value.done) {
|
|
service.metrics.complete();
|
|
controller.close();
|
|
return;
|
|
}
|
|
const frame = JSON.stringify({ ok: true, value: outcome.value.value });
|
|
if (encoder.encode(frame).byteLength > maxFrameBytes) {
|
|
service.metrics.fail();
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
'data: {"ok":false,"code":"RPC_MALFORMED","message":"Stream frame exceeds limit"}\n\n',
|
|
),
|
|
);
|
|
controller.close();
|
|
return;
|
|
}
|
|
controller.enqueue(encoder.encode(`data: ${frame}\n\n`));
|
|
next = iterator.next();
|
|
}
|
|
} catch {
|
|
service.metrics.fail();
|
|
controller.enqueue(
|
|
encoder.encode('data: {"ok":false,"code":"RPC_HANDLER","message":"Stream failed"}\n\n'),
|
|
);
|
|
controller.close();
|
|
}
|
|
},
|
|
async cancel() {
|
|
service.metrics.complete();
|
|
await iterator.return?.();
|
|
},
|
|
});
|
|
return new Response(body, {
|
|
headers: {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "private, no-store",
|
|
"x-accel-buffering": "no",
|
|
},
|
|
});
|
|
}
|
|
if (!("invoke" in service) || typeof service.invoke !== "function") {
|
|
return json({ ok: false, code: "RPC_UNKNOWN", message: "Unknown procedure", retryable: false });
|
|
}
|
|
return json(
|
|
await service.invoke(procedure, payload, req.headers.get(RPC_IDENTITY_HEADER) ?? undefined),
|
|
);
|
|
}
|