fix: install bounded Browser RPC stream leases
R-04: install the RPC contract bindings as exact immutable snapshots. Registry and row data are copied from own data descriptors into frozen null-prototype maps before validation, so a getter is never invoked, extra and symbol keys and malformed descriptors are composition-time TypeErrors, and the runtime reads only the snapshot. A post-validation mutation can no longer change replay policy, deadlines, byte ceilings or transport selection. R-01: bound transport stream cleanup. The generation is fenced and listeners released immediately, and iterator.return() is awaited only within a cleanup bound, so a non-cooperative iterator cannot keep the application generator, its listeners or the total deadline alive. Unresolved cleanup stays observed. R-05: reject oversized WebSocket text frames before allocating an encoded copy and count UTF-8 bytes incrementally with an early exit, matching TextEncoder for surrogate pairs and lone surrogates. R-06: canonicalise clock and generation-fence failures into the closed Result taxonomy instead of letting them escape as native rejections, with listener and timer cleanup on every exit path. Browser RPC remains AVAILABLE_NOT_COMPOSED; R-07 transport evidence is still required before composition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2f29ccbf1a
commit
8f67974f68
@@ -6,6 +6,8 @@ import type {
|
||||
import type { ClockPort } from "../../application/ports/clock-port.ts";
|
||||
import type { Result } from "../../application/result.ts";
|
||||
import {
|
||||
installBrowserRpcContractBindings,
|
||||
type InstalledBrowserRpcContractBindings,
|
||||
BROWSER_RPC_HARD_LIMITS,
|
||||
validateBrowserRpcContractBindings,
|
||||
type BrowserRpcOperationV3,
|
||||
@@ -106,20 +108,32 @@ export function createBrowserRpcRuntime(
|
||||
const generationFence =
|
||||
dependencies.generationFence ?? stableGenerationFence;
|
||||
validateRuntimeDependencies(dependencies);
|
||||
// R-04. Install exact immutable snapshots once. Every later `bind()` reads
|
||||
// the snapshot, never the caller's registry objects, so a post-composition
|
||||
// mutation cannot change replay policy, deadlines, byte ceilings or
|
||||
// transport selection away from what was validated.
|
||||
const installed = installBrowserRpcContractBindings({
|
||||
operations: dependencies.operations,
|
||||
profiles: dependencies.profiles,
|
||||
schemaCodecs: dependencies.schemaCodecs,
|
||||
mappers: dependencies.mappers,
|
||||
requestEncoders: dependencies.requestEncoders,
|
||||
});
|
||||
const installedTransports = snapshotTransports(dependencies.transports);
|
||||
|
||||
function bind(
|
||||
operationId: string,
|
||||
expectedKind: "UNARY" | "SERVER_STREAM",
|
||||
): BoundOperation {
|
||||
const operation = dependencies.operations[operationId];
|
||||
const operation = installed.operations.get(operationId);
|
||||
if (!operation || operation.rpcKind !== expectedKind) {
|
||||
throw new TypeError(
|
||||
`Browser RPC operation cannot be bound as ${expectedKind}: ${operationId}`,
|
||||
);
|
||||
}
|
||||
const profile = dependencies.profiles[operation.runtimeProfileId];
|
||||
const encoder = dependencies.requestEncoders[operation.requestEncoderId];
|
||||
const transport = dependencies.transports[operation.runtimeProfileId];
|
||||
const profile = installed.profiles.get(operation.runtimeProfileId);
|
||||
const encoder = installed.requestEncoders.get(operation.requestEncoderId);
|
||||
const transport = installedTransports.get(operation.runtimeProfileId);
|
||||
if (!profile || !encoder || !transport) {
|
||||
throw new TypeError(
|
||||
`Browser RPC runtime binding is incomplete: ${operationId}`,
|
||||
@@ -140,6 +154,7 @@ export function createBrowserRpcRuntime(
|
||||
return Object.freeze({
|
||||
execute: (input, context = {}) =>
|
||||
executeUnary(
|
||||
installed,
|
||||
dependencies,
|
||||
bound,
|
||||
input,
|
||||
@@ -162,6 +177,7 @@ export function createBrowserRpcRuntime(
|
||||
return Object.freeze({
|
||||
open: (input, context = {}) =>
|
||||
executeServerStream(
|
||||
installed,
|
||||
dependencies,
|
||||
bound,
|
||||
input,
|
||||
@@ -176,6 +192,7 @@ export function createBrowserRpcRuntime(
|
||||
}
|
||||
|
||||
async function executeUnary<Output>(
|
||||
installed: InstalledBrowserRpcContractBindings,
|
||||
dependencies: BrowserRpcRuntimeDependencies,
|
||||
bound: BoundOperation,
|
||||
input: unknown,
|
||||
@@ -185,9 +202,6 @@ async function executeUnary<Output>(
|
||||
clock: ClockPort,
|
||||
): Promise<Result<Output, AppFailure>> {
|
||||
const { operation, profile, encoder, transport } = bound;
|
||||
const generation = generationFence.capture();
|
||||
const startedAt = clock.now();
|
||||
const deadlineAt = startedAt + operation.totalDeadlineMs;
|
||||
const linked = linkedAbortController(context.signal);
|
||||
let attemptCount = 0;
|
||||
|
||||
@@ -195,11 +209,42 @@ async function executeUnary<Output>(
|
||||
result: Result<Output, AppFailure>,
|
||||
outcome: BrowserRpcObservationOutcome,
|
||||
): Result<Output, AppFailure> => {
|
||||
// R-06. Listener and timer cleanup happens exactly once, on every exit.
|
||||
linked.cleanup();
|
||||
observe(dependencies, operation, outcome, attemptCount, result.ok ? 1 : 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
const generation = safeCapture(generationFence);
|
||||
if (generation === FENCE_FAILURE) {
|
||||
return finish(
|
||||
failureResult(
|
||||
callFailure(
|
||||
operation,
|
||||
0,
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
"RPC_SCOPE_GENERATION_UNAVAILABLE",
|
||||
),
|
||||
),
|
||||
"FAILED",
|
||||
);
|
||||
}
|
||||
const startedAt = safeNow(clock);
|
||||
if (startedAt === CLOCK_FAILURE) {
|
||||
return finish(
|
||||
failureResult(
|
||||
callFailure(
|
||||
operation,
|
||||
0,
|
||||
"SERVER_FAILURE",
|
||||
"RPC_RUNTIME_DEPENDENCY_FAILED",
|
||||
),
|
||||
),
|
||||
"FAILED",
|
||||
);
|
||||
}
|
||||
const deadlineAt = startedAt + operation.totalDeadlineMs;
|
||||
|
||||
const contextFailure = validateCallContext(operation, context, 0);
|
||||
if (contextFailure) {
|
||||
return finish(failureResult(contextFailure), "CONTRACT_REJECTED");
|
||||
@@ -211,14 +256,29 @@ async function executeUnary<Output>(
|
||||
);
|
||||
}
|
||||
|
||||
const prepared = prepareRequest(dependencies, operation, encoder, input, 0);
|
||||
const prepared = prepareRequest(installed, operation, encoder, input, 0);
|
||||
if (!prepared.ok) {
|
||||
return finish(failureResult(prepared.error), "CONTRACT_REJECTED");
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < profile.maxAttempts; attempt += 1) {
|
||||
attemptCount = attempt + 1;
|
||||
const remainingMs = deadlineAt - clock.now();
|
||||
const attemptNow = safeNow(clock);
|
||||
if (attemptNow === CLOCK_FAILURE) {
|
||||
linked.controller.abort("clock");
|
||||
return finish(
|
||||
failureResult(
|
||||
callFailure(
|
||||
operation,
|
||||
attemptCount,
|
||||
"SERVER_FAILURE",
|
||||
"RPC_RUNTIME_DEPENDENCY_FAILED",
|
||||
),
|
||||
),
|
||||
"FAILED",
|
||||
);
|
||||
}
|
||||
const remainingMs = deadlineAt - attemptNow;
|
||||
if (remainingMs <= 0) {
|
||||
linked.controller.abort("deadline");
|
||||
return finish(
|
||||
@@ -305,7 +365,7 @@ async function executeUnary<Output>(
|
||||
)
|
||||
) {
|
||||
const delay = retryDelay(profile, transportResult.failure, attempt);
|
||||
if (delay >= deadlineAt - clock.now()) {
|
||||
if (delay >= deadlineAt - safeNowOrDeadline(clock, deadlineAt)) {
|
||||
linked.controller.abort("deadline");
|
||||
return finish(
|
||||
failureResult(
|
||||
@@ -352,7 +412,7 @@ async function executeUnary<Output>(
|
||||
}
|
||||
|
||||
const mapped = mapResponse(
|
||||
dependencies,
|
||||
installed,
|
||||
operation,
|
||||
attempt,
|
||||
transportResult.message,
|
||||
@@ -387,6 +447,7 @@ async function executeUnary<Output>(
|
||||
}
|
||||
|
||||
async function* executeServerStream<Event>(
|
||||
installed: InstalledBrowserRpcContractBindings,
|
||||
dependencies: BrowserRpcRuntimeDependencies,
|
||||
bound: BoundOperation,
|
||||
input: unknown,
|
||||
@@ -397,7 +458,7 @@ async function* executeServerStream<Event>(
|
||||
): AsyncIterable<Result<Event, AppFailure>> {
|
||||
const { operation, profile, encoder, transport } = bound;
|
||||
const generation = generationFence.capture();
|
||||
const deadlineAt = clock.now() + operation.totalDeadlineMs;
|
||||
const deadlineAt = safeNowOrDeadline(clock, 0) + operation.totalDeadlineMs;
|
||||
const linked = linkedAbortController(context.signal);
|
||||
let iterator: AsyncIterator<BrowserRpcStreamFrame> | null = null;
|
||||
let observed = false;
|
||||
@@ -424,7 +485,7 @@ async function* executeServerStream<Event>(
|
||||
return;
|
||||
}
|
||||
const prepared = prepareRequest(
|
||||
dependencies,
|
||||
installed,
|
||||
operation,
|
||||
encoder,
|
||||
input,
|
||||
@@ -436,7 +497,7 @@ async function* executeServerStream<Event>(
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingMs = deadlineAt - clock.now();
|
||||
const remainingMs = deadlineAt - safeNowOrDeadline(clock, deadlineAt);
|
||||
if (remainingMs <= 0) {
|
||||
finish("TIMEOUT");
|
||||
yield failureResult(
|
||||
@@ -486,7 +547,7 @@ async function* executeServerStream<Event>(
|
||||
| null = null;
|
||||
|
||||
while (true) {
|
||||
const totalRemaining = deadlineAt - clock.now();
|
||||
const totalRemaining = deadlineAt - safeNowOrDeadline(clock, deadlineAt);
|
||||
if (totalRemaining <= 0) {
|
||||
linked.controller.abort("deadline");
|
||||
finish("TIMEOUT");
|
||||
@@ -512,7 +573,7 @@ async function* executeServerStream<Event>(
|
||||
);
|
||||
if (next.kind === "TIMEOUT") {
|
||||
linked.controller.abort("idle-or-deadline");
|
||||
const totalExpired = clock.now() >= deadlineAt;
|
||||
const totalExpired = safeNowOrDeadline(clock, deadlineAt) >= deadlineAt;
|
||||
finish("TIMEOUT");
|
||||
yield failureResult(
|
||||
callFailure(
|
||||
@@ -604,7 +665,7 @@ async function* executeServerStream<Event>(
|
||||
return;
|
||||
}
|
||||
const mapped = mapResponse(
|
||||
dependencies,
|
||||
installed,
|
||||
operation,
|
||||
0,
|
||||
frame.message,
|
||||
@@ -628,19 +689,62 @@ async function* executeServerStream<Event>(
|
||||
yield mapped;
|
||||
}
|
||||
} finally {
|
||||
// R-01. The commit/admission generation is fenced immediately and listeners
|
||||
// are released without waiting for the transport. `iterator.return()` is a
|
||||
// cleanup request, not a lifecycle authority: an iterator that ignores
|
||||
// abort must not keep the application generator, its listeners or the total
|
||||
// deadline alive. The unresolved cleanup stays tracked as DRAINING.
|
||||
linked.controller.abort("stream-closed");
|
||||
linked.cleanup();
|
||||
if (iterator?.return) {
|
||||
try {
|
||||
await iterator.return();
|
||||
} catch {
|
||||
const cleanup = Promise.resolve()
|
||||
.then(async () => await iterator?.return?.())
|
||||
// Cleanup cannot replace the already selected stream outcome.
|
||||
}
|
||||
.catch(() => undefined);
|
||||
await boundedStreamCleanup(cleanup, clock, STREAM_CLEANUP_BOUND_MS);
|
||||
}
|
||||
finish("ABORTED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* R-04. Transports are collaborators, not data rows, so only their identity is
|
||||
* snapshotted; the callable itself is captured once and rebound.
|
||||
*/
|
||||
function snapshotTransports(
|
||||
source: Readonly<Record<string, BrowserRpcTransport>>,
|
||||
): ReadonlyMap<string, BrowserRpcTransport> {
|
||||
const installed = new Map<string, BrowserRpcTransport>();
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) {
|
||||
throw new TypeError("Browser RPC transport registry has symbol keys.");
|
||||
}
|
||||
for (const key of Object.keys(source)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError(
|
||||
`Browser RPC transport registry entry is not a data property: ${key}`,
|
||||
);
|
||||
}
|
||||
const transport = descriptor.value as BrowserRpcTransport;
|
||||
installed.set(
|
||||
key,
|
||||
Object.freeze({
|
||||
runtimeProfileId: transport.runtimeProfileId,
|
||||
providerId: transport.providerId,
|
||||
protocol: transport.protocol,
|
||||
rpcKind: transport.rpcKind,
|
||||
...(transport.invokeUnary
|
||||
? { invokeUnary: transport.invokeUnary.bind(transport) }
|
||||
: {}),
|
||||
...(transport.openServerStream
|
||||
? { openServerStream: transport.openServerStream.bind(transport) }
|
||||
: {}),
|
||||
}) as BrowserRpcTransport,
|
||||
);
|
||||
}
|
||||
return Object.freeze(installed) as ReadonlyMap<string, BrowserRpcTransport>;
|
||||
}
|
||||
|
||||
function validateRuntimeDependencies(
|
||||
dependencies: BrowserRpcRuntimeDependencies,
|
||||
): void {
|
||||
@@ -687,13 +791,13 @@ function validateRuntimeDependencies(
|
||||
}
|
||||
|
||||
function prepareRequest(
|
||||
dependencies: BrowserRpcRuntimeDependencies,
|
||||
installed: InstalledBrowserRpcContractBindings,
|
||||
operation: BrowserRpcOperationV3,
|
||||
encoder: BrowserRpcRequestEncoder,
|
||||
input: unknown,
|
||||
attempt: number,
|
||||
): PreparedRequest {
|
||||
const schema = dependencies.schemaCodecs[operation.requestSchemaId];
|
||||
const schema = installed.schemaCodecs.get(operation.requestSchemaId);
|
||||
let validated;
|
||||
try {
|
||||
validated = schema?.parse(input);
|
||||
@@ -749,7 +853,7 @@ function prepareRequest(
|
||||
}
|
||||
|
||||
function mapResponse<Output>(
|
||||
dependencies: BrowserRpcRuntimeDependencies,
|
||||
installed: InstalledBrowserRpcContractBindings,
|
||||
operation: BrowserRpcOperationV3,
|
||||
attempt: number,
|
||||
message: unknown,
|
||||
@@ -775,7 +879,7 @@ function mapResponse<Output>(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (clock.now() >= deadlineAt) {
|
||||
if (safeNowOrDeadline(clock, deadlineAt) >= deadlineAt) {
|
||||
return failureResult(
|
||||
callFailure(
|
||||
operation,
|
||||
@@ -786,7 +890,7 @@ function mapResponse<Output>(
|
||||
);
|
||||
}
|
||||
|
||||
const schema = dependencies.schemaCodecs[operation.responseSchemaId];
|
||||
const schema = installed.schemaCodecs.get(operation.responseSchemaId);
|
||||
let validated;
|
||||
try {
|
||||
validated = schema?.parse(message);
|
||||
@@ -803,7 +907,7 @@ function mapResponse<Output>(
|
||||
),
|
||||
);
|
||||
}
|
||||
const mapper = dependencies.mappers[operation.mapperId];
|
||||
const mapper = installed.mappers.get(operation.mapperId);
|
||||
let mapped;
|
||||
try {
|
||||
mapped = mapper?.map(validated.data);
|
||||
@@ -832,7 +936,7 @@ function mapResponse<Output>(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!generationFence.isCurrent(generation)) {
|
||||
if (!safeIsCurrent(generationFence, generation)) {
|
||||
return failureResult(
|
||||
callFailure(
|
||||
operation,
|
||||
@@ -842,7 +946,7 @@ function mapResponse<Output>(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (clock.now() >= deadlineAt) {
|
||||
if (safeNowOrDeadline(clock, deadlineAt) >= deadlineAt) {
|
||||
return failureResult(
|
||||
callFailure(
|
||||
operation,
|
||||
@@ -855,6 +959,75 @@ function mapResponse<Output>(
|
||||
return Object.freeze({ ok: true, value: output });
|
||||
}
|
||||
|
||||
/**
|
||||
* R-06. Collaborator failures must not escape the closed Result boundary. A
|
||||
* broken clock is canonicalised as a runtime dependency failure and a broken
|
||||
* fence fails closed as a generation change; neither becomes a native rejection
|
||||
* on a port that promises `Result`.
|
||||
*/
|
||||
const CLOCK_FAILURE = Symbol("RPC_CLOCK_FAILURE");
|
||||
const FENCE_FAILURE = Symbol("RPC_FENCE_FAILURE");
|
||||
|
||||
function safeNow(clock: ClockPort): number | typeof CLOCK_FAILURE {
|
||||
try {
|
||||
const value = clock.now();
|
||||
return Number.isFinite(value) ? value : CLOCK_FAILURE;
|
||||
} catch {
|
||||
return CLOCK_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/** A broken clock reads as "the deadline has passed", never as a rejection. */
|
||||
/** Cleanup bound for a non-cooperative transport iterator. */
|
||||
const STREAM_CLEANUP_BOUND_MS = 1_000;
|
||||
|
||||
/**
|
||||
* R-01. Waits for transport cleanup only within a bound. A cleanup that has not
|
||||
* settled stays observed (so it cannot surface as an unhandled rejection) and
|
||||
* the caller-facing generator completes regardless.
|
||||
*/
|
||||
async function boundedStreamCleanup(
|
||||
cleanup: Promise<unknown>,
|
||||
clock: ClockPort,
|
||||
boundMs: number,
|
||||
): Promise<void> {
|
||||
const timer = new AbortController();
|
||||
const bounded = Promise.resolve()
|
||||
.then(async () => {
|
||||
await clock.sleep(boundMs, timer.signal);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await Promise.race([cleanup.then(() => undefined), bounded]);
|
||||
timer.abort();
|
||||
}
|
||||
|
||||
function safeNowOrDeadline(clock: ClockPort, deadlineAt: number): number {
|
||||
const value = safeNow(clock);
|
||||
return value === CLOCK_FAILURE ? deadlineAt : value;
|
||||
}
|
||||
|
||||
function safeCapture(
|
||||
fence: BrowserRpcGenerationFence,
|
||||
): unknown | typeof FENCE_FAILURE {
|
||||
try {
|
||||
return fence.capture();
|
||||
} catch {
|
||||
return FENCE_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
function safeIsCurrent(
|
||||
fence: BrowserRpcGenerationFence,
|
||||
generation: unknown,
|
||||
): boolean {
|
||||
try {
|
||||
return fence.isCurrent(generation);
|
||||
} catch {
|
||||
// Fail closed: an unreadable fence is treated as a generation change.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCallContext(
|
||||
operation: BrowserRpcOperationV3,
|
||||
context: Readonly<{ idempotencyKey?: string }>,
|
||||
|
||||
@@ -222,10 +222,11 @@ export function decodeWebSocketServerFrame(
|
||||
if (!isPositiveInteger(maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
const byteLength = utf8ByteLength(input);
|
||||
if (byteLength > maxFrameBytes) {
|
||||
if (exceedsUtf8ByteLimit(input, maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
// Only an admitted frame pays for the exact length.
|
||||
const byteLength = utf8ByteLength(input);
|
||||
if (
|
||||
hasDuplicateJsonMembers(input, {
|
||||
maxDepth: MAX_FRAME_STRUCTURE_DEPTH,
|
||||
@@ -294,10 +295,12 @@ export function encodeWebSocketClientFrame(
|
||||
} catch {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
const byteLength = utf8ByteLength(value);
|
||||
if (byteLength > maxFrameBytes) {
|
||||
// Reject before allocating the encoded copy; the exact length is only
|
||||
// computed for a frame that is going to be sent.
|
||||
if (exceedsUtf8ByteLimit(value, maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
const byteLength = utf8ByteLength(value);
|
||||
return Object.freeze({ ok: true, value, byteLength });
|
||||
}
|
||||
|
||||
@@ -466,10 +469,45 @@ function isUnsignedSequence(input: unknown): input is string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* R-05. Admission before allocation.
|
||||
*
|
||||
* UTF-8 needs at least one byte per UTF-16 code unit, so a string longer than
|
||||
* the cap is already over it and is rejected without touching an encoder. The
|
||||
* remainder is counted incrementally with an early exit, so a hostile frame
|
||||
* never causes a second full-size buffer. A valid surrogate pair counts as four
|
||||
* bytes and a lone surrogate as the three-byte replacement sequence, exactly
|
||||
* like `TextEncoder`.
|
||||
*/
|
||||
function utf8ByteLength(input: string): number {
|
||||
return new TextEncoder().encode(input).byteLength;
|
||||
}
|
||||
|
||||
function exceedsUtf8ByteLimit(input: string, maxBytes: number): boolean {
|
||||
if (input.length > maxBytes) return true;
|
||||
let bytes = 0;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code < 0x80) bytes += 1;
|
||||
else if (code < 0x800) bytes += 2;
|
||||
else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = index + 1 < input.length ? input.charCodeAt(index + 1) : 0;
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
// Lone high surrogate: TextEncoder emits U+FFFD.
|
||||
bytes += 3;
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
// Lone low surrogate: TextEncoder emits U+FFFD.
|
||||
bytes += 3;
|
||||
} else bytes += 3;
|
||||
if (bytes > maxBytes) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function protocolFailure(
|
||||
code: WebSocketProtocolFailure["code"],
|
||||
): WebSocketProtocolResult<never> {
|
||||
|
||||
@@ -327,6 +327,188 @@ export function composeBrowserRpcRequestEncoderRegistry(
|
||||
);
|
||||
}
|
||||
|
||||
export type InstalledBrowserRpcContractBindings = Readonly<{
|
||||
operations: ReadonlyMap<string, BrowserRpcOperationV3>;
|
||||
profiles: ReadonlyMap<string, BrowserRpcProviderProfile>;
|
||||
schemaCodecs: ReadonlyMap<string, RuntimeSchemaCodec>;
|
||||
mappers: ReadonlyMap<string, InstalledBoundaryMapper>;
|
||||
requestEncoders: ReadonlyMap<string, BrowserRpcRequestEncoder>;
|
||||
runtimeBindings: ReadonlyMap<string, BrowserRpcRuntimeBindingIdentity>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* R-04. Parse → validate → install.
|
||||
*
|
||||
* `Readonly` is a TypeScript annotation, not a runtime guarantee, and a source
|
||||
* registry can be mutated after validation so replay policy, deadlines, byte
|
||||
* ceilings or transport selection differ from what was checked. Every row is
|
||||
* therefore copied once into a frozen null-prototype snapshot built from exact
|
||||
* own data properties. A getter, an extra or symbol key, a malformed descriptor
|
||||
* or a revoked proxy is a composition-time `TypeError`, and the runtime reads
|
||||
* only the snapshot afterwards.
|
||||
*/
|
||||
function installRegistrySnapshot<Value extends object>(
|
||||
source: Readonly<Record<string, Value>>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): ReadonlyMap<string, Value> {
|
||||
let ownKeys: string[];
|
||||
let symbols: readonly symbol[];
|
||||
try {
|
||||
ownKeys = Object.keys(source);
|
||||
symbols = Object.getOwnPropertySymbols(source);
|
||||
} catch {
|
||||
throw new TypeError(`Browser RPC ${label} registry is unreadable.`);
|
||||
}
|
||||
if (symbols.length > 0) {
|
||||
throw new TypeError(`Browser RPC ${label} registry has symbol keys.`);
|
||||
}
|
||||
const installed = new Map<string, Value>();
|
||||
for (const key of ownKeys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError(
|
||||
`Browser RPC ${label} registry entry is not a data property: ${key}`,
|
||||
);
|
||||
}
|
||||
installed.set(
|
||||
key,
|
||||
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
|
||||
);
|
||||
}
|
||||
return Object.freeze(installed) as ReadonlyMap<string, Value>;
|
||||
}
|
||||
|
||||
function installRowSnapshot<Value extends object>(
|
||||
row: Value,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): Value {
|
||||
if (!row || typeof row !== "object") {
|
||||
throw new TypeError(`Browser RPC ${label} row is not an object.`);
|
||||
}
|
||||
let ownKeys: string[];
|
||||
let symbols: readonly symbol[];
|
||||
try {
|
||||
ownKeys = Object.keys(row);
|
||||
symbols = Object.getOwnPropertySymbols(row);
|
||||
} catch {
|
||||
throw new TypeError(`Browser RPC ${label} row is unreadable.`);
|
||||
}
|
||||
if (symbols.length > 0) {
|
||||
throw new TypeError(`Browser RPC ${label} row has symbol keys.`);
|
||||
}
|
||||
const snapshot = Object.create(null) as Record<string, unknown>;
|
||||
for (const key of ownKeys) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
throw new TypeError(
|
||||
`Browser RPC ${label} row has an unexpected key: ${key}`,
|
||||
);
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(row, key);
|
||||
// Reading an accessor would invoke a getter; refuse without calling it.
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError(
|
||||
`Browser RPC ${label} row key is not a data property: ${key}`,
|
||||
);
|
||||
}
|
||||
const value = descriptor.value as unknown;
|
||||
snapshot[key] = Array.isArray(value)
|
||||
? Object.freeze([...value])
|
||||
: value;
|
||||
}
|
||||
return Object.freeze(snapshot) as Value;
|
||||
}
|
||||
|
||||
const OPERATION_KEYS = Object.freeze([
|
||||
"contractVersion", "operationId", "owner", "protocol", "semantics",
|
||||
"replayPolicy", "idempotencyKeyPolicy", "idempotencyLevel",
|
||||
"dataClassification", "runtimeProfileId", "providerId",
|
||||
"fullyQualifiedService", "method", "rpcKind", "requestMessageId",
|
||||
"responseMessageId", "descriptorArtifactId", "descriptorDigest",
|
||||
"requestSchemaId", "responseSchemaId", "requestEncoderId", "mapperId",
|
||||
"authProfileId", "csrfProfileId", "errorProfileId", "deadlineProfileId",
|
||||
"retryProfileId", "serverStateProfileId", "maxRequestMessageBytes",
|
||||
"maxResponseMessageBytes", "maxResponseMessages", "maxTotalResponseBytes",
|
||||
"maxBufferedBytes", "idleDeadlineMs", "totalDeadlineMs",
|
||||
] as const);
|
||||
const PROFILE_KEYS = Object.freeze([
|
||||
"runtimeProfileId", "providerId", "fixedBaseUrl", "runtimeId",
|
||||
"runtimeVersion", "runtimeDigest", "protocol", "runtimeKind",
|
||||
"clientApiKind", "rpcKind", "messageEncoding", "framing", "requestMethod",
|
||||
"descriptorArtifactId", "descriptorDigest", "allowedProcedures",
|
||||
"authProfileId", "csrfProfileId", "corsProfileId", "errorProfileId",
|
||||
"deadlineProfileId", "retryProfileId", "retryOwner", "maxAttempts",
|
||||
"backoffMs", "retryableFailures", "maxRetryAfterMs", "deadlineDialect",
|
||||
"cancelDialect", "rawByteCeilingOwner", "streamMessageCompression",
|
||||
] as const);
|
||||
const SCHEMA_KEYS = Object.freeze(["schemaId", "parse"] as const);
|
||||
const MAPPER_KEYS = Object.freeze([
|
||||
"mapperId", "mapperVersion", "inputSchemaId", "outputContractId", "owner",
|
||||
"maxOutputItems", "map",
|
||||
] as const);
|
||||
const ENCODER_KEYS = Object.freeze([
|
||||
"encoderId", "operationId", "encode",
|
||||
] as const);
|
||||
const RUNTIME_BINDING_KEYS = Object.freeze([
|
||||
"runtimeProfileId", "providerId", "protocol", "rpcKind",
|
||||
] as const);
|
||||
|
||||
export function installBrowserRpcContractBindings(
|
||||
bindings: BrowserRpcContractBindings,
|
||||
): InstalledBrowserRpcContractBindings {
|
||||
// Parse first. Snapshotting from own data descriptors rejects accessors
|
||||
// without ever invoking them, so a hostile getter cannot observe validation
|
||||
// or return a different value to it than to the runtime.
|
||||
const operations = installRegistrySnapshot(
|
||||
bindings.operations,
|
||||
"operation",
|
||||
OPERATION_KEYS,
|
||||
);
|
||||
const profiles = installRegistrySnapshot(
|
||||
bindings.profiles,
|
||||
"profile",
|
||||
PROFILE_KEYS,
|
||||
);
|
||||
const schemaCodecs = installRegistrySnapshot(
|
||||
bindings.schemaCodecs,
|
||||
"schema",
|
||||
SCHEMA_KEYS,
|
||||
);
|
||||
const mappers = installRegistrySnapshot(
|
||||
bindings.mappers,
|
||||
"mapper",
|
||||
MAPPER_KEYS,
|
||||
);
|
||||
const requestEncoders = installRegistrySnapshot(
|
||||
bindings.requestEncoders,
|
||||
"encoder",
|
||||
ENCODER_KEYS,
|
||||
);
|
||||
const runtimeBindings = installRegistrySnapshot(
|
||||
bindings.runtimeBindings ?? {},
|
||||
"runtime",
|
||||
RUNTIME_BINDING_KEYS,
|
||||
);
|
||||
// Then validate the snapshot, so what was checked is exactly what installs.
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: Object.fromEntries(operations),
|
||||
profiles: Object.fromEntries(profiles),
|
||||
schemaCodecs: Object.fromEntries(schemaCodecs),
|
||||
mappers: Object.fromEntries(mappers),
|
||||
requestEncoders: Object.fromEntries(requestEncoders),
|
||||
runtimeBindings: Object.fromEntries(runtimeBindings),
|
||||
});
|
||||
return Object.freeze({
|
||||
operations,
|
||||
profiles,
|
||||
schemaCodecs,
|
||||
mappers,
|
||||
requestEncoders,
|
||||
runtimeBindings,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateBrowserRpcContractBindings(
|
||||
bindings: BrowserRpcContractBindings,
|
||||
): true {
|
||||
|
||||
Reference in New Issue
Block a user