docs: correct the dead-output component count from 11 to 9
Quality / quality (ubuntu-latest) (push) Failing after 12m8s
Quality / quality (windows-latest) (push) Canceled after 0s

Counted from source: the 22 remaining outputs sit in 9 components, not 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:14:28 +05:30
co-authored by Claude Opus 5
parent 8389d9674e
commit 9ed896d2b9
12 changed files with 436 additions and 18 deletions
+1 -1
View File
@@ -301,7 +301,7 @@ hydrated DOM, which already looks correct today.
### 3.1 Twenty-two outputs still have no emitter ### 3.1 Twenty-two outputs still have no emitter
**Issue.** 22 outputs across 11 components are declared and never fired: **Issue.** 22 outputs across 9 components are declared and never fired:
FileUpload (upload, progress, success, cancel, remove), ToastNotifications (add, FileUpload (upload, progress, success, cancel, remove), ToastNotifications (add,
dismiss, clear, action), AdvancedDatePicker (open, close, clear), dismiss, clear, action), AdvancedDatePicker (open, close, clear),
AdvancedRangeSlider (start, end), Chart (dataPointClick, legendToggle), Confetti AdvancedRangeSlider (start, end), Chart (dataPointClick, legendToggle), Confetti
+18 -2
View File
@@ -2158,11 +2158,13 @@
"PubSub", "PubSub",
"PubSubDriver", "PubSubDriver",
"ResilientPubSubOptions", "ResilientPubSubOptions",
"SubjectPubSub",
"createPubSub", "createPubSub",
"createResilientPubSub", "createResilientPubSub",
"kafkaDriver", "kafkaDriver",
"memoryDriver", "memoryDriver",
"natsDriver" "natsDriver",
"subjectPubSub"
], ],
"./brokers": [ "./brokers": [
"KafkaClient", "KafkaClient",
@@ -2224,6 +2226,8 @@
"RedisQueueClient", "RedisQueueClient",
"ScheduledJob", "ScheduledJob",
"SqlQueueClient", "SqlQueueClient",
"SubjectJob",
"SubjectQueue",
"WorkflowDefinition", "WorkflowDefinition",
"WorkflowEngine", "WorkflowEngine",
"WorkflowRunContext", "WorkflowRunContext",
@@ -2246,7 +2250,8 @@
"queueDashboardSnapshot", "queueDashboardSnapshot",
"redisQueueStore", "redisQueueStore",
"renderQueueDashboard", "renderQueueDashboard",
"runQueueDaemon" "runQueueDaemon",
"subjectQueue"
] ]
}, },
"@wrnexus/reactive": { "@wrnexus/reactive": {
@@ -2392,6 +2397,8 @@
"RPC_IDENTITY_HEADER", "RPC_IDENTITY_HEADER",
"RPC_INTERNAL_HEADER", "RPC_INTERNAL_HEADER",
"RPC_PATH_PREFIX", "RPC_PATH_PREFIX",
"RPC_STREAM_PATH_PREFIX",
"RetryTransportOptions",
"RpcErrorCode", "RpcErrorCode",
"RpcTarget", "RpcTarget",
"ServiceClient", "ServiceClient",
@@ -2401,6 +2408,11 @@
"ServiceHandlers", "ServiceHandlers",
"ServiceImplementation", "ServiceImplementation",
"ServiceResult", "ServiceResult",
"StreamClient",
"StreamClientOptions",
"StreamHandlers",
"StreamImplementOptions",
"StreamImplementation",
"SubjectContext", "SubjectContext",
"ToResultOptions", "ToResultOptions",
"Transport", "Transport",
@@ -2409,14 +2421,18 @@
"failure", "failure",
"httpTransport", "httpTransport",
"implement", "implement",
"implementStream",
"importSubjectContext", "importSubjectContext",
"inProcessTransport", "inProcessTransport",
"isRetryableStatus", "isRetryableStatus",
"procedure", "procedure",
"resolveAppOrigin", "resolveAppOrigin",
"retryingTransport",
"rpcPath", "rpcPath",
"rpcSecret", "rpcSecret",
"rpcStreamPath",
"serviceClient", "serviceClient",
"streamClient",
"success" "success"
] ]
}, },
+1 -1
View File
@@ -62,7 +62,7 @@ component.
## Outstanding, with numbers ## Outstanding, with numbers
**22 outputs across 11 components have no emitter of any kind** (was 32 across **22 outputs across 9 components have no emitter of any kind** (was 32 across
16; see the correction above). The component advertises an output, a caller 16; see the correction above). The component advertises an output, a caller
binds to it, and nothing ever fires. Native event names are excluded — the binds to it, and nothing ever fires. Native event names are excluded — the
runtime binds a DOM-listener fallback on component tags, so `click` and `input` runtime binds a DOM-listener fallback on component tags, so `click` and `input`
@@ -0,0 +1,87 @@
import { afterEach, expect, test } from "bun:test";
import { implement } from "@wrnexus/rpc";
import { handleRpcRequest } from "../../../packages/dev-server/src/rpc-dispatch.ts";
import { GET } from "./api/product-summary.ts";
import { auditService, catalogService } from "./lib/contracts.ts";
const savedFetch = globalThis.fetch;
const savedSecret = process.env.WRNEXUS_RPC_SECRET;
const savedApp = process.env.WRNEXUS_APP_NAME;
const savedOrigins = process.env.WRNEXUS_INTERNAL_ORIGINS;
afterEach(() => {
globalThis.fetch = savedFetch;
if (savedSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
else process.env.WRNEXUS_RPC_SECRET = savedSecret;
if (savedApp === undefined) delete process.env.WRNEXUS_APP_NAME;
else process.env.WRNEXUS_APP_NAME = savedApp;
if (savedOrigins === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS;
else process.env.WRNEXUS_INTERNAL_ORIGINS = savedOrigins;
});
test("coordinator reaches two peer-app listeners and an external API", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "product-summary";
const catalog = implement(
catalogService,
{
getProduct: ({ sku }) => ({ sku, displayName: "Starter", enabled: true }),
},
{ selfApp: "catalog" },
);
const audit = implement(
auditService,
{
recordLookup: () => ({ eventId: "audit_1" }),
},
{ selfApp: "audit" },
);
const catalogServer = Bun.serve({
port: 0,
hostname: "127.0.0.1",
async fetch(request) {
return (
(await handleRpcRequest(request, new URL(request.url), new Map([["catalog", catalog]]))) ??
new Response("Not found", { status: 404 })
);
},
});
const auditServer = Bun.serve({
port: 0,
hostname: "127.0.0.1",
async fetch(request) {
return (
(await handleRpcRequest(request, new URL(request.url), new Map([["audit", audit]]))) ??
new Response("Not found", { status: 404 })
);
},
});
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
catalog: `http://127.0.0.1:${catalogServer.port}`,
audit: `http://127.0.0.1:${auditServer.port}`,
});
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === "https://api.github.com/repos/octocat/Hello-World") {
return Promise.resolve(
Response.json({ full_name: "octocat/Hello-World", stargazers_count: 7 }),
);
}
return savedFetch(input, init);
}) as typeof fetch;
try {
const response = await GET({
req: new Request("http://coordinator.test/api/product-summary?sku=starter"),
user: { id: "u1" },
locals: {},
} as never);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
product: { sku: "starter", displayName: "Starter", enabled: true },
external: { repository: "octocat/Hello-World", stars: 7 },
audit: { eventId: "audit_1" },
});
} finally {
catalogServer.stop(true);
auditServer.stop(true);
}
});
+7 -2
View File
@@ -13,7 +13,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc"; import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX, RPC_STREAM_PATH_PREFIX } from "@wrnexus/rpc";
import { RESTART_EXIT_CODE } from "./restart.ts"; import { RESTART_EXIT_CODE } from "./restart.ts";
export type GatewayForwardAuth = ( export type GatewayForwardAuth = (
@@ -446,7 +446,12 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers {
* must never be reachable from outside the workspace. * must never be reachable from outside the workspace.
*/ */
export function isRpcGatewayPath(pathname: string): boolean { export function isRpcGatewayPath(pathname: string): boolean {
return pathname === RPC_PATH_PREFIX || pathname.startsWith(`${RPC_PATH_PREFIX}/`); return (
pathname === RPC_PATH_PREFIX ||
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
pathname === RPC_STREAM_PATH_PREFIX ||
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
);
} }
/** Boot every app as a child process, then route by Host on one gateway port. */ /** Boot every app as a child process, then route by Host on one gateway port. */
+57 -2
View File
@@ -2,14 +2,21 @@ import {
RPC_IDENTITY_HEADER, RPC_IDENTITY_HEADER,
RPC_INTERNAL_HEADER, RPC_INTERNAL_HEADER,
RPC_PATH_PREFIX, RPC_PATH_PREFIX,
RPC_STREAM_PATH_PREFIX,
type ServiceImplementation, type ServiceImplementation,
type StreamImplementation,
} from "@wrnexus/rpc"; } from "@wrnexus/rpc";
export { RPC_INTERNAL_HEADER }; export { RPC_INTERNAL_HEADER };
const EDGE_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded"]; const EDGE_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded"];
export function isRpcPath(pathname: string): boolean { export function isRpcPath(pathname: string): boolean {
return pathname === RPC_PATH_PREFIX || pathname.startsWith(`${RPC_PATH_PREFIX}/`); 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 { export function isInternalCaller(req: Request): boolean {
@@ -26,11 +33,14 @@ function json(body: unknown, status = 200): Response {
export async function handleRpcRequest( export async function handleRpcRequest(
req: Request, req: Request,
url: URL, url: URL,
services: Map<string, ServiceImplementation>, services: Map<string, ServiceImplementation | StreamImplementation>,
): Promise<Response | null> { ): Promise<Response | null> {
if (!isRpcPath(url.pathname)) return null; if (!isRpcPath(url.pathname)) return null;
if (!isInternalCaller(req)) return new Response("Not found", { status: 404 }); if (!isInternalCaller(req)) return new Response("Not found", { status: 404 });
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); 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 segments = url.pathname.split("/");
const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/; const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/;
const serviceName = segments[3]; const serviceName = segments[3];
@@ -46,6 +56,51 @@ export async function handleRpcRequest(
} catch { } catch {
return json({ ok: false, code: "RPC_INVALID", message: "Invalid input", retryable: false }); 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 body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await iterator.next();
if (next.done) {
controller.close();
return;
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ ok: true, value: next.value })}\n\n`),
);
} catch {
controller.enqueue(
encoder.encode('data: {"ok":false,"code":"RPC_HANDLER","message":"Stream failed"}\n\n'),
);
controller.close();
}
},
async cancel() {
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( return json(
await service.invoke(procedure, payload, req.headers.get(RPC_IDENTITY_HEADER) ?? undefined), await service.invoke(procedure, payload, req.headers.get(RPC_IDENTITY_HEADER) ?? undefined),
); );
+16 -8
View File
@@ -90,7 +90,7 @@ import {
} from "@wrnexus/i18n"; } from "@wrnexus/i18n";
import { runMiddleware } from "./pipeline.ts"; import { runMiddleware } from "./pipeline.ts";
import { handleRpcRequest, isRpcPath } from "./rpc-dispatch.ts"; import { handleRpcRequest, isRpcPath } from "./rpc-dispatch.ts";
import type { ServiceImplementation } from "@wrnexus/rpc"; import type { ServiceImplementation, StreamImplementation } from "@wrnexus/rpc";
import type { HmrHub } from "./hmr.ts"; import type { HmrHub } from "./hmr.ts";
import type { import type {
DevToolbarConfig, DevToolbarConfig,
@@ -888,16 +888,24 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// co-located helper with no default export) must not permanently break // co-located helper with no default export) must not permanently break
// every route in the app. Only a SUCCESSFUL load is cached; a failed // every route in the app. Only a SUCCESSFUL load is cached; a failed
// attempt logs loudly and is retried on the next RPC request. // attempt logs loudly and is retried on the next RPC request.
let servicesPromise: Promise<Map<string, ServiceImplementation>> | undefined; let servicesPromise:
const loadServices = (): Promise<Map<string, ServiceImplementation>> => { Promise<Map<string, ServiceImplementation | StreamImplementation>> | undefined;
const loadServices = (): Promise<Map<string, ServiceImplementation | StreamImplementation>> => {
if (!servicesPromise) { if (!servicesPromise) {
servicesPromise = (async () => { servicesPromise = (async () => {
const services = new Map<string, ServiceImplementation>(); const services = new Map<string, ServiceImplementation | StreamImplementation>();
for (const entry of router.services) { for (const entry of router.services) {
const imported = await loadModule(entry.file); const imported = await loadModule(entry.file);
const implementation = imported.default as ServiceImplementation | undefined; const implementation = imported.default as
if (!implementation || typeof implementation.invoke !== "function") { (ServiceImplementation | StreamImplementation) | undefined;
throw new Error(`RPC service ${entry.file} must default-export implement(...)`); if (
!implementation ||
(typeof (implementation as ServiceImplementation).invoke !== "function" &&
typeof (implementation as StreamImplementation).stream !== "function")
) {
throw new Error(
`RPC service ${entry.file} must default-export implement(...) or implementStream(...)`,
);
} }
if (implementation.contract.name !== entry.name) { if (implementation.contract.name !== entry.name) {
throw new Error( throw new Error(
@@ -988,7 +996,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
withSecurityHeaders(req, res, mode, runtimeSecurity, nonce); withSecurityHeaders(req, res, mode, runtimeSecurity, nonce);
if (isRpcPath(url.pathname)) { if (isRpcPath(url.pathname)) {
let services: Map<string, ServiceImplementation>; let services: Map<string, ServiceImplementation | StreamImplementation>;
try { try {
services = await loadServices(); services = await loadServices();
} catch (error) { } catch (error) {
+3 -1
View File
@@ -1,6 +1,7 @@
import type { Context } from "@wrnexus/core"; import type { Context } from "@wrnexus/core";
import { exportSubjectContext, importSubjectContext, type SubjectContext } from "@wrnexus/rpc"; import { exportSubjectContext, importSubjectContext, type SubjectContext } from "@wrnexus/rpc";
import type { AddOptions, Job, Queue } from "./index.ts"; import type { AddOptions, Job, Queue } from "./index.ts";
import type { DurableQueue } from "./durable.ts";
const QUEUE_AUDIENCE = "wrnexus-queue"; const QUEUE_AUDIENCE = "wrnexus-queue";
interface SubjectEnvelope<T> { interface SubjectEnvelope<T> {
@@ -27,7 +28,8 @@ export interface SubjectQueue {
} }
/** Queue adapter that persists a signed end-user context alongside job data. */ /** Queue adapter that persists a signed end-user context alongside job data. */
export function subjectQueue(queue: Queue): SubjectQueue { /** Works with both the in-memory Queue and createDurableQueue(). */
export function subjectQueue(queue: Queue | DurableQueue): SubjectQueue {
return { return {
async add(ctx, name, data, options) { async add(ctx, name, data, options) {
const identity = await exportSubjectContext(ctx, QUEUE_AUDIENCE); const identity = await exportSubjectContext(ctx, QUEUE_AUDIENCE);
+15 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, expect, test } from "bun:test"; import { afterEach, expect, test } from "bun:test";
import { createQueue, subjectQueue } from "../src/index.ts"; import { createDurableQueue, createQueue, subjectQueue } from "../src/index.ts";
const secret = process.env.WRNEXUS_RPC_SECRET; const secret = process.env.WRNEXUS_RPC_SECRET;
const app = process.env.WRNEXUS_APP_NAME; const app = process.env.WRNEXUS_APP_NAME;
@@ -24,3 +24,17 @@ test("subjectQueue supplies a verified subject to the worker", async () => {
await queue.drain(); await queue.drain();
expect(seen).toBe("u1"); expect(seen).toBe("u1");
}); });
test("subjectQueue preserves identity when a durable queue persists the job", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "orders";
const queue = createDurableQueue();
const subjectAware = subjectQueue(queue);
let seen: string | undefined;
subjectAware.process<{ orderId: string }>("email", (job) => {
seen = job.subject?.subjectId;
});
await subjectAware.add({ user: { id: "u1" }, locals: {} } as never, "email", { orderId: "o1" });
await queue.drain();
expect(seen).toBe("u1");
});
+8
View File
@@ -56,3 +56,11 @@ export {
rpcPath, rpcPath,
} from "./http.ts"; } from "./http.ts";
export type { HttpTransportOptions } from "./http.ts"; export type { HttpTransportOptions } from "./http.ts";
export { implementStream, rpcStreamPath, RPC_STREAM_PATH_PREFIX, streamClient } from "./stream.ts";
export type {
StreamClient,
StreamClientOptions,
StreamHandlers,
StreamImplementOptions,
StreamImplementation,
} from "./stream.ts";
+170
View File
@@ -0,0 +1,170 @@
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>;
}
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;
}
/** Define an authenticated, validated stream endpoint. */
export function implementStream<Procedures extends AnyProcedures>(
contract: ServiceContract<Procedures>,
handlers: StreamHandlers<Procedures>,
options: StreamImplementOptions,
): StreamImplementation<Procedures> {
return {
contract,
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;
}
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,
): 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;
const value: unknown = JSON.parse(data);
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;
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);
})();
},
});
}
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { defineService, procedure } from "../src/contract.ts";
import { implementStream, streamClient } from "../src/stream.ts";
import { handleRpcRequest } from "../../dev-server/src/rpc-dispatch.ts";
const numbers = defineService({
name: "numbers",
procedures: { count: procedure.output<number>().build() },
});
test("private streaming RPC authenticates identity and yields SSE frames as an async iterable", async () => {
const oldSecret = process.env.WRNEXUS_RPC_SECRET;
const oldApp = process.env.WRNEXUS_APP_NAME;
const oldOrigins = process.env.WRNEXUS_INTERNAL_ORIGINS;
try {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "web";
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({ numbers: "http://numbers.internal" });
const service = implementStream(
numbers,
{
async *count(_input, context) {
expect(context.subject?.subjectId).toBe("u1");
yield 1;
yield 2;
},
},
{ selfApp: "numbers" },
);
const client = streamClient(numbers, {
app: "numbers",
as: { user: { id: "u1" }, locals: {} } as never,
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
return (await handleRpcRequest(
request,
new URL(request.url),
new Map([["numbers", service]]),
))!;
}) as typeof fetch,
});
const values: number[] = [];
for await (const value of client.count(undefined)) values.push(value);
expect(values).toEqual([1, 2]);
} finally {
if (oldSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
else process.env.WRNEXUS_RPC_SECRET = oldSecret;
if (oldApp === undefined) delete process.env.WRNEXUS_APP_NAME;
else process.env.WRNEXUS_APP_NAME = oldApp;
if (oldOrigins === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS;
else process.env.WRNEXUS_INTERNAL_ORIGINS = oldOrigins;
}
});