Files
ClintchizandClaude Opus 5 69020b2555
Quality / quality (ubuntu-latest) (push) Failing after 10m7s
Quality / quality (windows-latest) (push) Canceled after 0s
docs: make the component sections executable in one pass
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>
2026-08-09 10:34:22 +05:30

233 lines
8.2 KiB
TypeScript

import type { Context } from "@wrnexus/core";
import { RPC_ERROR_CODES, ServiceError } from "./errors.ts";
import { exportSubjectContext, importSubjectContext, type SubjectContext } from "./identity.ts";
import { resolveAppOrigin, RPC_INTERNAL_HEADER } from "./http.ts";
import type {
AnyProcedures,
InferProcedureInput,
InferProcedureOutput,
ServiceContract,
} from "./types.ts";
export const RPC_STREAM_PATH_PREFIX = "/__wrnexus/rpc-stream";
export function rpcStreamPath(service: string, procedure: string): string {
return `${RPC_STREAM_PATH_PREFIX}/${service}/${procedure}`;
}
export interface StreamImplementation<Procedures extends AnyProcedures = AnyProcedures> {
contract: ServiceContract<Procedures>;
stream(procedure: string, payload: unknown, identity?: string): AsyncIterable<unknown>;
streamOptions: Required<Pick<StreamImplementOptions, "maxFrameBytes" | "heartbeatMs">>;
metrics: StreamMetrics;
}
export type StreamHandlers<Procedures extends AnyProcedures> = {
[K in keyof Procedures]: (
input: InferProcedureInput<Procedures[K]>,
ctx: { subject?: import("./identity.ts").SubjectContext },
) => AsyncIterable<InferProcedureOutput<Procedures[K]>>;
};
export interface StreamImplementOptions {
selfApp: string;
checkPermission?: (permission: string, subject?: SubjectContext) => Promise<boolean> | boolean;
/** Maximum serialized SSE data frame. Default: 64 KiB. */
maxFrameBytes?: number;
/** Emit an SSE comment while idle. Default: 15 seconds. */
heartbeatMs?: number;
}
export interface StreamMetricsSnapshot {
started: number;
completed: number;
failed: number;
active: number;
}
export class StreamMetrics {
private started = 0;
private completed = 0;
private failed = 0;
private active = 0;
begin() {
this.started++;
this.active++;
}
complete() {
this.completed++;
this.active = Math.max(0, this.active - 1);
}
fail() {
this.failed++;
this.active = Math.max(0, this.active - 1);
}
snapshot(): StreamMetricsSnapshot {
return {
started: this.started,
completed: this.completed,
failed: this.failed,
active: this.active,
};
}
}
/** Define an authenticated, validated stream endpoint. */
export function implementStream<Procedures extends AnyProcedures>(
contract: ServiceContract<Procedures>,
handlers: StreamHandlers<Procedures>,
options: StreamImplementOptions,
): StreamImplementation<Procedures> {
const maxFrameBytes = options.maxFrameBytes ?? 64 * 1024;
const heartbeatMs = options.heartbeatMs ?? 15_000;
if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 1)
throw new RangeError("rpc stream maxFrameBytes must be a positive integer");
if (!Number.isInteger(heartbeatMs) || heartbeatMs < 1)
throw new RangeError("rpc stream heartbeatMs must be a positive integer");
return {
contract,
streamOptions: { maxFrameBytes, heartbeatMs },
metrics: new StreamMetrics(),
stream(procedure, payload, identity) {
const handler = Object.hasOwn(handlers, procedure)
? handlers[procedure as keyof Procedures]
: undefined;
if (!handler) throw new ServiceError(RPC_ERROR_CODES.unknown, "Unknown procedure");
return (async function* () {
const definition = contract.procedures[procedure as keyof Procedures];
let subject: SubjectContext | undefined;
if (identity) {
try {
subject = await importSubjectContext(identity, options.selfApp);
} catch {
throw new ServiceError(RPC_ERROR_CODES.identity, "Invalid identity");
}
}
if (definition.permission) {
if (
!options.checkPermission ||
!(await options.checkPermission(definition.permission, subject))
) {
throw new ServiceError(RPC_ERROR_CODES.denied, "Forbidden");
}
}
let input = payload;
if (definition.input) {
let parsed: { ok: boolean; value?: unknown };
try {
parsed = definition.input.parse(payload as Record<string, unknown>);
} catch {
throw new ServiceError(RPC_ERROR_CODES.invalid, "Invalid input");
}
if (!parsed.ok) throw new ServiceError(RPC_ERROR_CODES.invalid, "Invalid input");
input = parsed.value;
}
yield* (
handler as (
input: unknown,
ctx: { subject?: import("./identity.ts").SubjectContext },
) => AsyncIterable<unknown>
)(input, { subject });
})();
},
};
}
export interface StreamClientOptions {
app?: string;
as?: Context;
fetch?: typeof fetch;
signal?: AbortSignal;
/** Reject oversized server frames before parsing. Default: 64 KiB. */
maxFrameBytes?: number;
}
export type StreamClient<Procedures extends AnyProcedures> = {
[K in keyof Procedures]: (
input: InferProcedureInput<Procedures[K]>,
) => AsyncIterable<InferProcedureOutput<Procedures[K]>>;
};
function decodeFrames(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal,
maxFrameBytes = 64 * 1024,
): AsyncIterable<unknown> {
return (async function* () {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (!signal?.aborted) {
const next = await reader.read();
if (next.done) break;
buffer += decoder.decode(next.value, { stream: true });
let boundary: number;
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const data = frame
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6);
if (!data) continue;
if (new TextEncoder().encode(data).byteLength > maxFrameBytes) {
throw new ServiceError(RPC_ERROR_CODES.malformed, "Stream frame exceeds limit");
}
let value: unknown;
try {
value = JSON.parse(data);
} catch {
throw new ServiceError(RPC_ERROR_CODES.malformed, "Malformed stream response");
}
if (!value || typeof value !== "object" || !("ok" in value))
throw new ServiceError(RPC_ERROR_CODES.malformed, "Malformed stream response");
const result = value as { ok: boolean; value?: unknown; code?: string; message?: string };
if (!result.ok)
throw new ServiceError(
result.code ?? RPC_ERROR_CODES.handler,
result.message ?? "Stream failed",
);
yield result.value;
}
}
} finally {
reader.cancel().catch(() => {});
}
})();
}
export function streamClient<Procedures extends AnyProcedures>(
contract: ServiceContract<Procedures>,
options: StreamClientOptions = {},
): StreamClient<Procedures> {
const app = options.app ?? contract.name;
const doFetch = options.fetch ?? fetch;
const maxFrameBytes = options.maxFrameBytes ?? 64 * 1024;
if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 1)
throw new RangeError("rpc stream maxFrameBytes must be a positive integer");
return new Proxy({} as StreamClient<Procedures>, {
get(_target, property) {
if (typeof property !== "string" || !Object.hasOwn(contract.procedures, property))
return undefined;
return (input: unknown) =>
(async function* () {
let identity: string | undefined;
if (options.as) identity = await exportSubjectContext(options.as, app);
const headers: Record<string, string> = {
"content-type": "application/json",
[RPC_INTERNAL_HEADER]: "1",
accept: "text/event-stream",
};
if (identity) headers["x-wrnexus-rpc-identity"] = identity;
const response = await doFetch(
`${resolveAppOrigin(app)}${rpcStreamPath(contract.name, property)}`,
{ method: "POST", headers, body: JSON.stringify(input ?? {}), signal: options.signal },
);
if (!response.ok || !response.body)
throw new ServiceError(RPC_ERROR_CODES.transport, "Service unavailable");
yield* decodeFrames(response.body, options.signal, maxFrameBytes);
})();
},
});
}