docs: measure the runtime and the generated client modules
Quality / quality (ubuntu-latest) (push) Failing after 13m21s
Quality / quality (windows-latest) (push) Canceled after 0s

Adds a per-subsystem measurement of reactive.js, made by minifying it
repeatedly with one subsystem removed rather than counting source bytes.

This corrects the earlier audit on both figures and on the conclusion drawn
from them. Component controllers are 23,722 bytes minified / 6,660 gzipped --
30.6% of transfer, not the "about 18%" previously claimed -- and splitting them
out saves 6.6 kB gzipped on a typical page, not "3-4 kB". Measured against the
example app, / and /login use none of the ten controllers and /layout uses one,
so most pages download and parse the lot for nothing.

The larger finding is that the runtime is not where the weight is. One page
parses 490,212 decoded bytes across 11 generated client modules while
transferring 21,026, and the largest module is 89.8% duplicated lines: the
state-restore prologue appears 162 times because client-codegen.ts inlines the
sync into every peer alias of every client function. Gzip hides it on the wire,
but parse cost follows decoded bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:02:36 +05:30
co-authored by Claude Opus 5
parent 30d1632252
commit 5112cc1a62
20 changed files with 643 additions and 5 deletions
+20 -1
View File
@@ -105,6 +105,19 @@
"typescript": "^5.9.2", "typescript": "^5.9.2",
}, },
}, },
"examples/inter-app-api-showcase": {
"name": "inter-app-api-showcase",
"version": "0.8.6",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*",
"@wrnexus/validation": "workspace:*",
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2",
},
},
"packages/ai": { "packages/ai": {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.8.6", "version": "0.8.6",
@@ -404,6 +417,10 @@
"packages/pubsub": { "packages/pubsub": {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.8.6", "version": "0.8.6",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*",
},
}, },
"packages/pwa": { "packages/pwa": {
"name": "@wrnexus/pwa", "name": "@wrnexus/pwa",
@@ -418,6 +435,7 @@
"version": "0.8.6", "version": "0.8.6",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*",
}, },
}, },
"packages/reactive": { "packages/reactive": {
@@ -450,7 +468,6 @@
"name": "@wrnexus/rpc", "name": "@wrnexus/rpc",
"version": "0.8.6", "version": "0.8.6",
"dependencies": { "dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/helpers": "workspace:*", "@wrnexus/helpers": "workspace:*",
"@wrnexus/jwt": "workspace:*", "@wrnexus/jwt": "workspace:*",
@@ -1058,6 +1075,8 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inter-app-api-showcase": ["inter-app-api-showcase@workspace:examples/inter-app-api-showcase"],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+142
View File
@@ -466,6 +466,148 @@ confusing ways.
--- ---
## 4.6 Runtime and client-module size — measured
A per-subsystem measurement of `reactive.js` and of the generated client
modules, made by minifying the runtime repeatedly with one subsystem removed
each time. Source-byte share was not used: it overstates code that minifies
well and understates code that does not, and the split/keep decision turns on
the real number.
**This corrects the earlier audit**, which claimed component controllers were
"about 18%" of the runtime and concluded that splitting saves "3-4 kB gzipped".
Both figures were wrong, and the conclusion that followed from them was wrong.
### The runtime today
`reactive.js` is **70,101 bytes minified, 21,736 gzipped**. Removing each
subsystem and re-minifying gives its true cost:
| Subsystem | Minified | Share |
| ----------------- | -------- | ----- |
| Select/combobox | 7,852 | 11.2% |
| PinInput | 3,734 | 5.3% |
| async boundaries | 3,151 | 4.5% |
| Navbar | 2,419 | 3.5% |
| splitters | 2,083 | 3.0% |
| roving focus | 1,646 | 2.3% |
| modal dialogs | 1,611 | 2.3% |
| csr fetch | 1,547 | 2.2% |
| anchored overlays | 1,419 | 2.0% |
| scrollspy | 1,373 | 2.0% |
| preferences | 1,045 | 1.5% |
| toast | 534 | 0.8% |
Component-specific controllers (everything except async boundaries and csr
fetch, which are framework features) total **23,722 minified / 6,660 gzipped —
30.6% of what a visitor downloads.** The irreducible core is 46,379 minified /
15,076 gzipped: the expression engine, the scope and reactivity core, and loop
diffing.
### How much of it a page actually uses
Measured against the example app by checking which controller markers appear in
the served HTML:
| Page | Controllers used |
| ------------- | ------------------ |
| `/` | **0 of 10** |
| `/login` | **0 of 10** |
| `/layout` | 1 of 10 (splitter) |
| `/navigation` | 5 of 10 |
A typical page downloads and parses 6.6 kB gzipped of controller code it never
executes. The Select controller — the single largest item at 7,852 bytes — is
used by none of the pages above.
**Change.** Split the component controllers out of the core runtime and load
them on demand, keyed on the marker attribute that already gates each one
(`data-wrn-select`, `data-wrn-splitter`, `data-wrn-scrollspy` and so on). The
gating logic exists; only the loading boundary is missing. Keep the core
runtime as one immutable-cached file.
**How to test.** Assert the core bundle size, and per page assert that a
controller chunk is requested only when its marker is present:
```bash
bun run scripts/lib/measure-runtime-size.ts # core must stay under budget
```
Plus a browser check on `/`: zero controller chunks requested.
### The bigger problem: generated client modules
The runtime is not where the weight is. On `/navigation`:
- **490,212 bytes decoded** across 11 client modules, **21,026 transferred**
a 23:1 compression ratio.
- The largest single module is **269,117 bytes** decoded, of which **89.8% is
duplicated lines**.
- One line appears **162 times**: `brand = context.state.brand; topLinks =
context.state.topLinks; ...` — the full state-restore prologue.
Gzip hides this on the wire, but **parse and compile cost scales with decoded
bytes, not transferred bytes**. Half a megabyte of JavaScript is parsed to run
one page.
**Cause.** `packages/compiler/src/client-codegen.ts:228-264` inlines the state
sync into _every peer-function alias, in every client function_. Each alias
emits `syncStateToContext` once and `syncStateFromContext` three times — the
catch path, the promise `finally`, and the synchronous path. The output is
O(functions x peers x state variables). With ~19 state variables and 81 peer
aliases in that module, that is several thousand generated assignments.
**Change.** Hoist the sync out of the per-alias wrapper. The cheapest version
with no change to how bodies are written: emit **one** pair of closures per
client function and have every peer alias call them, instead of inlining the
sync per alias:
```js
const __flush = () => {
context.state.brand = brand; /* ... */
};
const __restore = () => {
brand = context.state.brand; /* ... */
};
const __peer =
(name) =>
(...args) => {
__flush();
let r;
try {
r = context.functions[name](...args);
} catch (e) {
__restore();
throw e;
}
if (r && typeof r.then === "function") return Promise.resolve(r).finally(__restore);
__restore();
return r;
};
const doThing = __peer("doThing");
```
That removes the peer multiplier — the dominant factor — and takes the 81
copies down to roughly one per function. It is a codegen change only, with no
change to semantics or to how anyone writes a component.
A larger follow-up, if the first is not enough: keep state in a single object
and rewrite state identifiers in the body to reference it, which removes the
per-variable multiplier as well. That one needs the body transform and should
be measured before it is attempted.
**How to test.** Pin decoded size, because gzip hides regressions here:
```bash
bun run build
# assert the largest generated client module is under budget, DECODED not gzipped
```
Add the ratio itself as a signal: any module compressing better than about 10:1
is duplicating itself and should fail the check. Existing behaviour is covered
by the current suite, so correctness is the 1,427 tests; this is purely a size
assertion on top.
## 5. Order of work ## 5. Order of work
Ranked by return, not by size. The first item changes the cost of every item Ranked by return, not by size. The first item changes the cost of every item
+19
View File
@@ -0,0 +1,19 @@
# Inter-app + external API showcase
`GET /api/product-summary?sku=starter` demonstrates one request handler making:
1. an RPC request to the `catalog` app (`getProduct`);
2. an external HTTPS request to GitHub's public REST API; and
3. an RPC request to the `audit` app (`recordLookup`).
The handler deliberately forwards `as: ctx` only to WRNexus peer apps. The RPC package turns that into a short-lived subject/tenant token; it is never forwarded to GitHub. Each peer app must implement the same contract from `app/lib/contracts.ts` (normally a shared workspace package) under `app/services/`, and must authorize its own procedures.
Before running this app, configure all three apps with the same private internal-origin map and a distinct, 32+ character RPC secret:
```sh
WRNEXUS_RPC_SECRET=replace-with-a-private-32-character-minimum-secret
WRNEXUS_APP_NAME=product-summary
WRNEXUS_INTERNAL_ORIGINS={"catalog":"http://127.0.0.1:4101","audit":"http://127.0.0.1:4102"}
```
The peer app processes must remain private; the public gateway blocks the RPC route by design. Run with `bun run --cwd examples/inter-app-api-showcase dev`.
@@ -0,0 +1,60 @@
import type { Context } from "@wrnexus/core";
import { httpTransport, serviceClient } from "@wrnexus/rpc";
import { auditService, catalogService } from "../lib/contracts.ts";
interface GitHubRepository {
full_name?: unknown;
stargazers_count?: unknown;
}
function requestedSku(ctx: Context): string {
return new URL(ctx.req.url).searchParams.get("sku")?.trim() || "starter";
}
/** GET /api/product-summary?sku=starter */
export async function GET(ctx: Context): Promise<Response> {
const sku = requestedSku(ctx);
const transport = httpTransport();
// Inter-app call #1: query the catalog app. `{ as: ctx }` forwards the
// signed subject/tenant context; catalog still authorizes independently.
const catalog = serviceClient(catalogService, { app: "catalog", as: ctx, transport });
const product = await catalog.getProduct({ sku });
// External API call: GitHub's public repository endpoint. Do not send the
// user's RPC identity token or internal headers to external services.
let githubResponse: Response;
try {
githubResponse = await fetch("https://api.github.com/repos/octocat/Hello-World", {
headers: { accept: "application/vnd.github+json", "user-agent": "wrnexus-example" },
signal: ctx.req.signal,
});
} catch {
return Response.json({ error: "External repository lookup failed." }, { status: 502 });
}
if (!githubResponse.ok) {
return Response.json({ error: "External repository lookup failed." }, { status: 502 });
}
const github = (await githubResponse.json()) as GitHubRepository;
if (typeof github.full_name !== "string" || typeof github.stargazers_count !== "number") {
return Response.json(
{ error: "External repository returned an unexpected response." },
{ status: 502 },
);
}
// Inter-app call #2: record the completed lookup in the audit app. This is
// intentionally awaited: callers learn whether the audit record was saved.
const audit = serviceClient(auditService, { app: "audit", as: ctx, transport });
const receipt = await audit.recordLookup({
sku: product.sku,
repository: github.full_name,
stars: github.stargazers_count,
});
return Response.json({
product,
external: { repository: github.full_name, stars: github.stargazers_count },
audit: receipt,
});
}
@@ -0,0 +1,11 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
test("product summary composes one external request with two peer-app calls", () => {
const source = readFileSync(join(import.meta.dir, "api", "product-summary.ts"), "utf8");
expect(source).toContain('serviceClient(catalogService, { app: "catalog", as: ctx, transport })');
expect(source).toContain('serviceClient(auditService, { app: "audit", as: ctx, transport })');
expect(source).toContain('fetch("https://api.github.com/repos/octocat/Hello-World"');
expect(source).toContain("ctx.req.signal");
});
@@ -0,0 +1,34 @@
import { defineService, procedure } from "@wrnexus/rpc";
import { v } from "@wrnexus/validation";
/**
* In a real multi-app workspace, put these contracts in a shared package and
* import that package from this app and each peer. They live together here so
* the example is self-contained.
*/
export const catalogService = defineService({
name: "catalog",
procedures: {
getProduct: procedure
.input(v.object({ sku: v.string() }))
.output<{ sku: string; displayName: string; enabled: boolean }>()
.idempotent()
.build(),
},
});
export const auditService = defineService({
name: "audit",
procedures: {
recordLookup: procedure
.input(
v.object({
sku: v.string(),
repository: v.string(),
stars: v.number(),
}),
)
.output<{ eventId: string }>()
.build(),
},
});
@@ -0,0 +1,22 @@
{
"name": "inter-app-api-showcase",
"version": "0.8.6",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run ../../packages/cli/src/index.ts dev .",
"build": "bun run ../../packages/cli/src/index.ts build .",
"test": "bun test",
"typecheck": "tsc --noEmit -p tsconfig.json",
"check": "bun run typecheck && bun run test && bun run build"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*",
"@wrnexus/validation": "workspace:*"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
}
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"] },
"include": ["app"]
}
+4
View File
@@ -4,6 +4,10 @@
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*"
},
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./brokers": "./src/brokers.ts", "./brokers": "./src/brokers.ts",
+2
View File
@@ -87,3 +87,5 @@ export { createResilientPubSub, PresenceChannel } from "./resilient.ts";
export type { MessageEnvelope, ResilientPubSubOptions, PresenceMember } from "./resilient.ts"; export type { MessageEnvelope, ResilientPubSubOptions, PresenceMember } from "./resilient.ts";
export { natsDriver, kafkaDriver } from "./brokers.ts"; export { natsDriver, kafkaDriver } from "./brokers.ts";
export type { NatsClient, KafkaClient } from "./brokers.ts"; export type { NatsClient, KafkaClient } from "./brokers.ts";
export { subjectPubSub } from "./subject.ts";
export type { SubjectPubSub } from "./subject.ts";
+49
View File
@@ -0,0 +1,49 @@
import type { Context } from "@wrnexus/core";
import { exportSubjectContext, importSubjectContext, type SubjectContext } from "@wrnexus/rpc";
import type { PubSub } from "./index.ts";
const PUBSUB_AUDIENCE = "wrnexus-pubsub";
interface SubjectEnvelope<T> {
payload: T;
identity?: string;
}
export interface SubjectPubSub {
publish<T>(ctx: Context, topic: string, message: T): Promise<void>;
subscribe<T>(
pattern: string,
handler: (message: T, topic: string, subject?: SubjectContext) => void | Promise<void>,
): () => void;
}
/**
* Authenticated pub/sub envelope. The token uses a fixed, purpose-specific
* audience; subscribers verify it before exposing the message to a handler.
*/
export function subjectPubSub(bus: PubSub): SubjectPubSub {
return {
async publish(ctx, topic, message) {
const identity = await exportSubjectContext(ctx, PUBSUB_AUDIENCE);
await bus.publish<SubjectEnvelope<typeof message>>(topic, {
payload: message,
...(identity ? { identity } : {}),
});
},
subscribe(pattern, handler) {
return bus.subscribe<SubjectEnvelope<unknown>>(pattern, async (envelope, topic) => {
if (!envelope || typeof envelope !== "object" || !("payload" in envelope)) return;
let subject: SubjectContext | undefined;
if (envelope.identity !== undefined) {
if (typeof envelope.identity !== "string") return;
try {
subject = await importSubjectContext(envelope.identity, PUBSUB_AUDIENCE);
} catch {
return; // Never downgrade a malformed claimed identity to anonymous.
}
}
await handler(envelope.payload as never, topic, subject);
});
},
};
}
+24
View File
@@ -0,0 +1,24 @@
import { afterEach, expect, test } from "bun:test";
import { createPubSub, subjectPubSub } from "../src/index.ts";
const secret = process.env.WRNEXUS_RPC_SECRET;
const app = process.env.WRNEXUS_APP_NAME;
afterEach(() => {
if (secret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
else process.env.WRNEXUS_RPC_SECRET = secret;
if (app === undefined) delete process.env.WRNEXUS_APP_NAME;
else process.env.WRNEXUS_APP_NAME = app;
});
test("subjectPubSub carries and verifies the publishing subject", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "orders";
const bus = subjectPubSub(createPubSub());
let seen: string | undefined;
bus.subscribe<{ id: string }>("order:created", (message, _topic, subject) => {
expect(message.id).toBe("o1");
seen = subject?.subjectId;
});
await bus.publish({ user: { id: "u1" }, locals: {} } as never, "order:created", { id: "o1" });
expect(seen).toBe("u1");
});
+2 -1
View File
@@ -8,6 +8,7 @@
".": "./src/index.ts" ".": "./src/index.ts"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*" "@wrnexus/core": "workspace:*",
"@wrnexus/rpc": "workspace:*"
} }
} }
+2
View File
@@ -314,4 +314,6 @@ export type {
WorkflowStatus, WorkflowStatus,
WorkflowStore, WorkflowStore,
} from "./workflow.ts"; } from "./workflow.ts";
export { subjectQueue } from "./subject.ts";
export type { SubjectJob, SubjectQueue } from "./subject.ts";
import { createExecutionContext, type ExecutionContext } from "@wrnexus/core"; import { createExecutionContext, type ExecutionContext } from "@wrnexus/core";
+56
View File
@@ -0,0 +1,56 @@
import type { Context } from "@wrnexus/core";
import { exportSubjectContext, importSubjectContext, type SubjectContext } from "@wrnexus/rpc";
import type { AddOptions, Job, Queue } from "./index.ts";
const QUEUE_AUDIENCE = "wrnexus-queue";
interface SubjectEnvelope<T> {
payload: T;
identity?: string;
}
export interface SubjectJob<T> extends Omit<Job<SubjectEnvelope<T>>, "data"> {
data: T;
subject?: SubjectContext;
}
export interface SubjectQueue {
add<T>(
ctx: Context,
name: string,
data: T,
options?: AddOptions,
): Promise<Job<SubjectEnvelope<T>>>;
process<T>(
name: string,
handler: (job: SubjectJob<T>, context: { signal: AbortSignal }) => void | Promise<void>,
): void;
}
/** Queue adapter that persists a signed end-user context alongside job data. */
export function subjectQueue(queue: Queue): SubjectQueue {
return {
async add(ctx, name, data, options) {
const identity = await exportSubjectContext(ctx, QUEUE_AUDIENCE);
return queue.add(name, { payload: data, ...(identity ? { identity } : {}) }, options);
},
process(name, handler) {
queue.process<SubjectEnvelope<unknown>>(name, async (job, context) => {
const envelope = job.data;
if (!envelope || typeof envelope !== "object" || !("payload" in envelope)) return;
let subject: SubjectContext | undefined;
if (envelope.identity !== undefined) {
if (typeof envelope.identity !== "string") return;
try {
subject = await importSubjectContext(envelope.identity, QUEUE_AUDIENCE);
} catch {
return;
}
}
await handler(
{ ...job, data: envelope.payload as never, ...(subject ? { subject } : {}) },
context,
);
});
},
};
}
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, expect, test } from "bun:test";
import { createQueue, subjectQueue } from "../src/index.ts";
const secret = process.env.WRNEXUS_RPC_SECRET;
const app = process.env.WRNEXUS_APP_NAME;
afterEach(() => {
if (secret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
else process.env.WRNEXUS_RPC_SECRET = secret;
if (app === undefined) delete process.env.WRNEXUS_APP_NAME;
else process.env.WRNEXUS_APP_NAME = app;
});
test("subjectQueue supplies a verified subject to the worker", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "orders";
const queue = createQueue();
const subjectAware = subjectQueue(queue);
let seen: string | undefined;
subjectAware.process<{ orderId: string }>("email", (job) => {
expect(job.data.orderId).toBe("o1");
seen = job.subject?.subjectId;
});
await subjectAware.add({ user: { id: "u1" }, locals: {} } as never, "email", { orderId: "o1" });
await queue.drain();
expect(seen).toBe("u1");
});
+5 -1
View File
@@ -66,7 +66,11 @@ export function serviceClient<Procedures extends AnyProcedures>(
const callPromise = options.transport.call( const callPromise = options.transport.call(
{ app, service: contract.name, procedure: property }, { app, service: contract.name, procedure: property },
input, input,
{ signal: controller.signal, ...(identity ? { identity } : {}) }, {
signal: controller.signal,
idempotent: contract.procedures[property as keyof Procedures].idempotent === true,
...(identity ? { identity } : {}),
},
); );
try { try {
const result = await Promise.race([callPromise, timeout]); const result = await Promise.race([callPromise, timeout]);
+8 -2
View File
@@ -31,8 +31,14 @@ export {
} from "./identity.ts"; } from "./identity.ts";
export type { ExportOptions, ImportOptions, SubjectContext } from "./identity.ts"; export type { ExportOptions, ImportOptions, SubjectContext } from "./identity.ts";
export { inProcessTransport } from "./transport.ts"; export { inProcessTransport, retryingTransport } from "./transport.ts";
export type { CallOptions, InProcessHandler, RpcTarget, Transport } from "./transport.ts"; export type {
CallOptions,
InProcessHandler,
RetryTransportOptions,
RpcTarget,
Transport,
} from "./transport.ts";
export { implement } from "./server.ts"; export { implement } from "./server.ts";
export type { export type {
HandlerContext, HandlerContext,
+84
View File
@@ -10,12 +10,96 @@ export interface RpcTarget {
export interface CallOptions { export interface CallOptions {
signal?: AbortSignal; signal?: AbortSignal;
identity?: string; identity?: string;
/** Supplied from the declared procedure; only these calls may be retried. */
idempotent?: boolean;
} }
export interface Transport { export interface Transport {
call(target: RpcTarget, payload: unknown, options: CallOptions): Promise<ServiceResult>; call(target: RpcTarget, payload: unknown, options: CallOptions): Promise<ServiceResult>;
} }
export interface RetryTransportOptions {
/** Retries after the initial attempt. Default: 2. */
retries?: number;
/** Initial exponential-backoff delay in milliseconds. Default: 50. */
backoffMs?: number;
/** Consecutive retryable failures before the target circuit opens. Default: 3. */
circuitFailureThreshold?: number;
/** How long an open circuit rejects calls before one probe is allowed. Default: 5s. */
circuitCooldownMs?: number;
now?: () => number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
}
function targetKey(target: RpcTarget): string {
return `${target.app}/${target.service}/${target.procedure}`;
}
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
}
/**
* Add bounded retry and a per-procedure circuit breaker to any transport.
* The idempotency bit comes from the contract and is not caller-controlled.
*/
export function retryingTransport(base: Transport, options: RetryTransportOptions = {}): Transport {
const retries = options.retries ?? 2;
const backoffMs = options.backoffMs ?? 50;
const threshold = options.circuitFailureThreshold ?? 3;
const cooldownMs = options.circuitCooldownMs ?? 5_000;
const now = options.now ?? Date.now;
const sleep = options.sleep ?? defaultSleep;
if (!Number.isInteger(retries) || retries < 0)
throw new RangeError("rpc retries must be a non-negative integer");
if (!Number.isFinite(backoffMs) || backoffMs < 0)
throw new RangeError("rpc backoffMs must be non-negative");
if (!Number.isInteger(threshold) || threshold < 1)
throw new RangeError("rpc circuitFailureThreshold must be positive");
if (!Number.isFinite(cooldownMs) || cooldownMs < 1)
throw new RangeError("rpc circuitCooldownMs must be positive");
const circuits = new Map<string, { failures: number; openUntil: number }>();
return {
async call(target, payload, callOptions) {
const key = targetKey(target);
const circuit = circuits.get(key);
if (circuit && circuit.openUntil > now()) {
return failure(RPC_ERROR_CODES.transport, "Service temporarily unavailable");
}
if (circuit?.openUntil) circuits.delete(key); // cooldown: allow one probe
const attempts = callOptions.idempotent ? retries + 1 : 1;
let result: ServiceResult = failure(RPC_ERROR_CODES.transport, "Service unreachable");
for (let attempt = 0; attempt < attempts; attempt++) {
if (callOptions.signal?.aborted) return failure(RPC_ERROR_CODES.transport, "Call aborted");
result = await base.call(target, payload, callOptions);
if (result.ok || !result.retryable) {
if (result.ok) circuits.delete(key);
return result;
}
if (attempt + 1 < attempts) await sleep(backoffMs * 2 ** attempt, callOptions.signal);
}
const failures = (circuits.get(key)?.failures ?? 0) + 1;
circuits.set(key, {
failures,
openUntil: failures >= threshold ? now() + cooldownMs : 0,
});
return result;
},
};
}
export type InProcessHandler = ( export type InProcessHandler = (
payload: unknown, payload: unknown,
identity?: string, identity?: string,
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import {
RPC_ERROR_CODES,
failure,
retryingTransport,
success,
type Transport,
} from "../src/index.ts";
function failingTransport(): { transport: Transport; calls: () => number } {
let count = 0;
return {
transport: {
async call() {
count++;
return failure(RPC_ERROR_CODES.transport, "down");
},
},
calls: () => count,
};
}
describe("retryingTransport", () => {
test("retries only declared idempotent calls", async () => {
const retryable = failingTransport();
const write = failingTransport();
const options = { retries: 2, backoffMs: 0 };
await retryingTransport(retryable.transport, options).call(
{ app: "billing", service: "invoice", procedure: "get" },
{},
{ idempotent: true },
);
await retryingTransport(write.transport, options).call(
{ app: "billing", service: "invoice", procedure: "create" },
{},
{ idempotent: false },
);
expect(retryable.calls()).toBe(3);
expect(write.calls()).toBe(1);
});
test("opens a circuit after repeated exhausted failures and recovers after cooldown", async () => {
let clock = 0;
let calls = 0;
const base: Transport = {
async call() {
calls++;
return calls < 3 ? failure(RPC_ERROR_CODES.transport, "down") : success("ok");
},
};
const transport = retryingTransport(base, {
retries: 0,
circuitFailureThreshold: 2,
circuitCooldownMs: 10,
now: () => clock,
});
const target = { app: "billing", service: "invoice", procedure: "get" };
await transport.call(target, {}, { idempotent: true });
await transport.call(target, {}, { idempotent: true });
expect(await transport.call(target, {}, { idempotent: true })).toMatchObject({
ok: false,
code: RPC_ERROR_CODES.transport,
});
expect(calls).toBe(2);
clock = 11;
expect(await transport.call(target, {}, { idempotent: true })).toEqual(success("ok"));
});
});