fix: keep Browser RPC collaborator input and output inside the contract
RPC-RR-02. The server-stream path captured the generation fence outside its protected boundary and raceWithin invoked clock.sleep outside a promise boundary, so a synchronous throw from either escaped the Result contract and skipped the listener and timer release. Both now run inside the boundary, and release moved to finally. RPC-RR-03. The runtime snapshotted its transports only after validating the caller's raw objects, which ran their accessors first. It now decodes the registry from own data descriptors before anything reads it — refusing an accessor without invoking it and rejecting extra, inherited and symbol-keyed fields — and validates that snapshot. Every installed binding registry is a read facade over a private store instead of a frozen Map whose set, delete and clear still worked. RPC-RR-04. Transport results and stream frames are decoded per union variant from own data descriptors into new frozen values. A throwing getter, an inherited or extra field, a symbol key, an unknown failure code and an out-of-range retryAfterMs all close as protocol failures instead of escaping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6a8281a941
commit
bd90e0c983
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
createReadOnlyRegistry,
|
||||||
|
type ReadOnlyRegistry,
|
||||||
|
} from "../../contracts/read-only-registry.ts";
|
||||||
import type {
|
import type {
|
||||||
BrowserRpcGenerationFence,
|
BrowserRpcGenerationFence,
|
||||||
BrowserRpcServerStreamPort,
|
BrowserRpcServerStreamPort,
|
||||||
@@ -107,7 +111,11 @@ export function createBrowserRpcRuntime(
|
|||||||
const clock = dependencies.clock ?? systemClock;
|
const clock = dependencies.clock ?? systemClock;
|
||||||
const generationFence =
|
const generationFence =
|
||||||
dependencies.generationFence ?? stableGenerationFence;
|
dependencies.generationFence ?? stableGenerationFence;
|
||||||
validateRuntimeDependencies(dependencies);
|
// RPC-RR-03. Snapshot before validating. Reading the caller's transport
|
||||||
|
// objects first would run their accessors, letting a hostile getter observe
|
||||||
|
// validation and then return something else to the runtime.
|
||||||
|
const installedTransports = snapshotTransports(dependencies.transports);
|
||||||
|
validateRuntimeDependencies(dependencies, installedTransports);
|
||||||
// R-04. Install exact immutable snapshots once. Every later `bind()` reads
|
// R-04. Install exact immutable snapshots once. Every later `bind()` reads
|
||||||
// the snapshot, never the caller's registry objects, so a post-composition
|
// the snapshot, never the caller's registry objects, so a post-composition
|
||||||
// mutation cannot change replay policy, deadlines, byte ceilings or
|
// mutation cannot change replay policy, deadlines, byte ceilings or
|
||||||
@@ -119,7 +127,6 @@ export function createBrowserRpcRuntime(
|
|||||||
mappers: dependencies.mappers,
|
mappers: dependencies.mappers,
|
||||||
requestEncoders: dependencies.requestEncoders,
|
requestEncoders: dependencies.requestEncoders,
|
||||||
});
|
});
|
||||||
const installedTransports = snapshotTransports(dependencies.transports);
|
|
||||||
|
|
||||||
function bind(
|
function bind(
|
||||||
operationId: string,
|
operationId: string,
|
||||||
@@ -457,7 +464,6 @@ async function* executeServerStream<Event>(
|
|||||||
clock: ClockPort,
|
clock: ClockPort,
|
||||||
): AsyncIterable<Result<Event, AppFailure>> {
|
): AsyncIterable<Result<Event, AppFailure>> {
|
||||||
const { operation, profile, encoder, transport } = bound;
|
const { operation, profile, encoder, transport } = bound;
|
||||||
const generation = generationFence.capture();
|
|
||||||
const deadlineAt = safeNowOrDeadline(clock, 0) + operation.totalDeadlineMs;
|
const deadlineAt = safeNowOrDeadline(clock, 0) + operation.totalDeadlineMs;
|
||||||
const linked = linkedAbortController(context.signal);
|
const linked = linkedAbortController(context.signal);
|
||||||
let iterator: AsyncIterator<BrowserRpcStreamFrame> | null = null;
|
let iterator: AsyncIterator<BrowserRpcStreamFrame> | null = null;
|
||||||
@@ -470,6 +476,23 @@ async function* executeServerStream<Event>(
|
|||||||
observe(dependencies, operation, outcome, 1, messageCount);
|
observe(dependencies, operation, outcome, 1, messageCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// RPC-RR-02. The fence is a caller collaborator, so a synchronous throw from
|
||||||
|
// it is an ordinary failure of this call. Capturing outside the protected
|
||||||
|
// boundary let that throw escape the stream's Result contract entirely.
|
||||||
|
const generation = safeCapture(generationFence);
|
||||||
|
if (generation === FENCE_FAILURE) {
|
||||||
|
finish("FAILED");
|
||||||
|
yield failureResult(
|
||||||
|
callFailure(
|
||||||
|
operation,
|
||||||
|
0,
|
||||||
|
"SCOPE_GENERATION_CHANGED",
|
||||||
|
"RPC_SCOPE_GENERATION_UNAVAILABLE",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contextFailure = validateCallContext(operation, context, 0);
|
const contextFailure = validateCallContext(operation, context, 0);
|
||||||
if (contextFailure) {
|
if (contextFailure) {
|
||||||
@@ -711,9 +734,23 @@ async function* executeServerStream<Event>(
|
|||||||
* R-04. Transports are collaborators, not data rows, so only their identity is
|
* R-04. Transports are collaborators, not data rows, so only their identity is
|
||||||
* snapshotted; the callable itself is captured once and rebound.
|
* snapshotted; the callable itself is captured once and rebound.
|
||||||
*/
|
*/
|
||||||
|
const TRANSPORT_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
"runtimeProfileId",
|
||||||
|
"providerId",
|
||||||
|
"protocol",
|
||||||
|
"rpcKind",
|
||||||
|
"invokeUnary",
|
||||||
|
"openServerStream",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC-RR-03. Decodes the transport registry from own data descriptors only, so
|
||||||
|
* no accessor on a caller's object is ever invoked, and returns a read facade
|
||||||
|
* whose backing store cannot be reached by `set`, `delete` or `clear`.
|
||||||
|
*/
|
||||||
function snapshotTransports(
|
function snapshotTransports(
|
||||||
source: Readonly<Record<string, BrowserRpcTransport>>,
|
source: Readonly<Record<string, BrowserRpcTransport>>,
|
||||||
): ReadonlyMap<string, BrowserRpcTransport> {
|
): ReadOnlyRegistry<string, BrowserRpcTransport> {
|
||||||
const installed = new Map<string, BrowserRpcTransport>();
|
const installed = new Map<string, BrowserRpcTransport>();
|
||||||
if (Object.getOwnPropertySymbols(source).length > 0) {
|
if (Object.getOwnPropertySymbols(source).length > 0) {
|
||||||
throw new TypeError("Browser RPC transport registry has symbol keys.");
|
throw new TypeError("Browser RPC transport registry has symbol keys.");
|
||||||
@@ -725,28 +762,59 @@ function snapshotTransports(
|
|||||||
`Browser RPC transport registry entry is not a data property: ${key}`,
|
`Browser RPC transport registry entry is not a data property: ${key}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const transport = descriptor.value as BrowserRpcTransport;
|
const transport = descriptor.value as unknown;
|
||||||
|
if (!transport || typeof transport !== "object") {
|
||||||
|
throw new TypeError(
|
||||||
|
`Browser RPC transport registry entry is not an object: ${key}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Object.getOwnPropertySymbols(transport).length > 0) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Browser RPC transport row has symbol keys: ${key}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const row: Record<string, unknown> = Object.create(null);
|
||||||
|
for (const field of Object.getOwnPropertyNames(transport)) {
|
||||||
|
if (!TRANSPORT_KEYS.has(field)) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Browser RPC transport row has an unexpected key: ${key}.${field}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fieldDescriptor = Object.getOwnPropertyDescriptor(
|
||||||
|
transport,
|
||||||
|
field,
|
||||||
|
);
|
||||||
|
if (!fieldDescriptor || !("value" in fieldDescriptor)) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Browser RPC transport row key is not a data property: ${key}.${field}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
row[field] = fieldDescriptor.value;
|
||||||
|
}
|
||||||
|
const invokeUnary = row.invokeUnary;
|
||||||
|
const openServerStream = row.openServerStream;
|
||||||
installed.set(
|
installed.set(
|
||||||
key,
|
key,
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
runtimeProfileId: transport.runtimeProfileId,
|
runtimeProfileId: row.runtimeProfileId,
|
||||||
providerId: transport.providerId,
|
providerId: row.providerId,
|
||||||
protocol: transport.protocol,
|
protocol: row.protocol,
|
||||||
rpcKind: transport.rpcKind,
|
rpcKind: row.rpcKind,
|
||||||
...(transport.invokeUnary
|
...(typeof invokeUnary === "function"
|
||||||
? { invokeUnary: transport.invokeUnary.bind(transport) }
|
? { invokeUnary: invokeUnary.bind(transport) }
|
||||||
: {}),
|
: {}),
|
||||||
...(transport.openServerStream
|
...(typeof openServerStream === "function"
|
||||||
? { openServerStream: transport.openServerStream.bind(transport) }
|
? { openServerStream: openServerStream.bind(transport) }
|
||||||
: {}),
|
: {}),
|
||||||
}) as BrowserRpcTransport,
|
}) as BrowserRpcTransport,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Object.freeze(installed) as ReadonlyMap<string, BrowserRpcTransport>;
|
return createReadOnlyRegistry(installed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRuntimeDependencies(
|
function validateRuntimeDependencies(
|
||||||
dependencies: BrowserRpcRuntimeDependencies,
|
dependencies: BrowserRpcRuntimeDependencies,
|
||||||
|
transports: ReadOnlyRegistry<string, BrowserRpcTransport>,
|
||||||
): void {
|
): void {
|
||||||
const runtimeBindings: Record<
|
const runtimeBindings: Record<
|
||||||
string,
|
string,
|
||||||
@@ -755,9 +823,7 @@ function validateRuntimeDependencies(
|
|||||||
"runtimeProfileId" | "providerId" | "protocol" | "rpcKind"
|
"runtimeProfileId" | "providerId" | "protocol" | "rpcKind"
|
||||||
>
|
>
|
||||||
> = Object.create(null);
|
> = Object.create(null);
|
||||||
for (const [profileId, transport] of Object.entries(
|
for (const [profileId, transport] of transports.entries()) {
|
||||||
dependencies.transports,
|
|
||||||
)) {
|
|
||||||
if (
|
if (
|
||||||
profileId !== transport.runtimeProfileId ||
|
profileId !== transport.runtimeProfileId ||
|
||||||
Object.hasOwn(runtimeBindings, profileId)
|
Object.hasOwn(runtimeBindings, profileId)
|
||||||
@@ -782,7 +848,7 @@ function validateRuntimeDependencies(
|
|||||||
runtimeBindings: Object.freeze(runtimeBindings),
|
runtimeBindings: Object.freeze(runtimeBindings),
|
||||||
});
|
});
|
||||||
for (const operation of Object.values(dependencies.operations)) {
|
for (const operation of Object.values(dependencies.operations)) {
|
||||||
if (!dependencies.transports[operation.runtimeProfileId]) {
|
if (!transports.has(operation.runtimeProfileId)) {
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
`Browser RPC transport is missing: ${operation.operationId}`,
|
`Browser RPC transport is missing: ${operation.operationId}`,
|
||||||
);
|
);
|
||||||
@@ -1072,29 +1138,145 @@ function safelyMatches<Output>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC-RR-04. A transport value is decoded, never adopted.
|
||||||
|
*
|
||||||
|
* `in` and a direct property read run accessors, so a throwing getter escapes
|
||||||
|
* the Result contract and an inherited or extra field passes unseen. Each
|
||||||
|
* variant is decoded from own data descriptors into a new frozen value, so a
|
||||||
|
* transport that mutates its own object after returning it cannot change what
|
||||||
|
* the runtime already admitted.
|
||||||
|
*/
|
||||||
|
function ownDataValue(source: unknown, key: string): unknown {
|
||||||
|
if (source === null || typeof source !== "object") return undefined;
|
||||||
|
try {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||||
|
if (!descriptor || !("value" in descriptor)) return undefined;
|
||||||
|
return descriptor.value;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactOwnKeys(
|
||||||
|
source: unknown,
|
||||||
|
allowed: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
if (source === null || typeof source !== "object") return false;
|
||||||
|
try {
|
||||||
|
if (Object.getOwnPropertySymbols(source).length > 0) return false;
|
||||||
|
for (const key of Object.getOwnPropertyNames(source)) {
|
||||||
|
if (!allowed.has(key)) return false;
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||||
|
if (!descriptor || !("value" in descriptor)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNARY_OK_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
"ok",
|
||||||
|
"message",
|
||||||
|
"encodedBytes",
|
||||||
|
]);
|
||||||
|
const UNARY_FAILED_KEYS: ReadonlySet<string> = new Set(["ok", "failure"]);
|
||||||
|
const FRAME_MESSAGE_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
"kind",
|
||||||
|
"message",
|
||||||
|
"encodedBytes",
|
||||||
|
]);
|
||||||
|
const FRAME_TERMINAL_OK_KEYS: ReadonlySet<string> = new Set(["kind", "ok"]);
|
||||||
|
const FRAME_TERMINAL_FAILED_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
"kind",
|
||||||
|
"ok",
|
||||||
|
"failure",
|
||||||
|
]);
|
||||||
|
const TRANSPORT_FAILURE_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
"code",
|
||||||
|
"retryAfterMs",
|
||||||
|
]);
|
||||||
|
|
||||||
function validateUnaryTransportResult(
|
function validateUnaryTransportResult(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): BrowserRpcUnaryTransportResult | null {
|
): BrowserRpcUnaryTransportResult | null {
|
||||||
if (!value || typeof value !== "object" || !("ok" in value)) return null;
|
const ok = ownDataValue(value, "ok");
|
||||||
const candidate = value as BrowserRpcUnaryTransportResult;
|
if (typeof ok !== "boolean") return null;
|
||||||
if (candidate.ok) {
|
if (ok) {
|
||||||
return validEncodedByteCount(candidate.encodedBytes, Number.MAX_SAFE_INTEGER)
|
if (!exactOwnKeys(value, UNARY_OK_KEYS)) return null;
|
||||||
? candidate
|
const encodedBytes = ownDataValue(value, "encodedBytes");
|
||||||
: null;
|
if (!validEncodedByteCount(encodedBytes, Number.MAX_SAFE_INTEGER)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
ok: true as const,
|
||||||
|
message: ownDataValue(value, "message"),
|
||||||
|
encodedBytes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return validTransportFailure(candidate.failure) ? candidate : null;
|
if (!exactOwnKeys(value, UNARY_FAILED_KEYS)) return null;
|
||||||
|
const failure = decodeTransportFailure(ownDataValue(value, "failure"));
|
||||||
|
return failure === null
|
||||||
|
? null
|
||||||
|
: Object.freeze({ ok: false as const, failure });
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateStreamFrame(value: unknown): BrowserRpcStreamFrame | null {
|
function validateStreamFrame(value: unknown): BrowserRpcStreamFrame | null {
|
||||||
if (!value || typeof value !== "object" || !("kind" in value)) return null;
|
const kind = ownDataValue(value, "kind");
|
||||||
const frame = value as BrowserRpcStreamFrame;
|
if (kind === "MESSAGE") {
|
||||||
if (frame.kind === "MESSAGE") {
|
if (!exactOwnKeys(value, FRAME_MESSAGE_KEYS)) return null;
|
||||||
return validEncodedByteCount(frame.encodedBytes, Number.MAX_SAFE_INTEGER)
|
const encodedBytes = ownDataValue(value, "encodedBytes");
|
||||||
? frame
|
if (!validEncodedByteCount(encodedBytes, Number.MAX_SAFE_INTEGER)) {
|
||||||
: null;
|
return null;
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "MESSAGE" as const,
|
||||||
|
message: ownDataValue(value, "message"),
|
||||||
|
encodedBytes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (frame.kind !== "TERMINAL" || typeof frame.ok !== "boolean") return null;
|
if (kind !== "TERMINAL") return null;
|
||||||
return frame.ok || validTransportFailure(frame.failure) ? frame : null;
|
const ok = ownDataValue(value, "ok");
|
||||||
|
if (typeof ok !== "boolean") return null;
|
||||||
|
if (ok) {
|
||||||
|
if (!exactOwnKeys(value, FRAME_TERMINAL_OK_KEYS)) return null;
|
||||||
|
return Object.freeze({ kind: "TERMINAL" as const, ok: true as const });
|
||||||
|
}
|
||||||
|
if (!exactOwnKeys(value, FRAME_TERMINAL_FAILED_KEYS)) return null;
|
||||||
|
const failure = decodeTransportFailure(ownDataValue(value, "failure"));
|
||||||
|
return failure === null
|
||||||
|
? null
|
||||||
|
: Object.freeze({
|
||||||
|
kind: "TERMINAL" as const,
|
||||||
|
ok: false as const,
|
||||||
|
failure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTransportFailure(
|
||||||
|
value: unknown,
|
||||||
|
): BrowserRpcTransportFailure | null {
|
||||||
|
if (!exactOwnKeys(value, TRANSPORT_FAILURE_KEYS)) return null;
|
||||||
|
const code = ownDataValue(value, "code");
|
||||||
|
const retryAfterMs = ownDataValue(value, "retryAfterMs");
|
||||||
|
if (!TRANSPORT_FAILURE_CODES.has(code as BrowserRpcTransportFailureCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (retryAfterMs === undefined) {
|
||||||
|
return Object.freeze({ code: code as BrowserRpcTransportFailureCode });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(retryAfterMs) ||
|
||||||
|
(retryAfterMs as number) < 0 ||
|
||||||
|
(retryAfterMs as number) > BROWSER_RPC_HARD_LIMITS.maxRetryAfterMs
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
code: code as BrowserRpcTransportFailureCode,
|
||||||
|
retryAfterMs: retryAfterMs as number,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function validTransportFailure(
|
function validTransportFailure(
|
||||||
@@ -1240,8 +1422,12 @@ function failureResult(
|
|||||||
return Object.freeze({ ok: false, error });
|
return Object.freeze({ ok: false, error });
|
||||||
}
|
}
|
||||||
|
|
||||||
function validEncodedByteCount(value: number, maximum: number): boolean {
|
function validEncodedByteCount(
|
||||||
|
value: unknown,
|
||||||
|
maximum: number,
|
||||||
|
): value is number {
|
||||||
return (
|
return (
|
||||||
|
typeof value === "number" &&
|
||||||
Number.isSafeInteger(value) &&
|
Number.isSafeInteger(value) &&
|
||||||
value >= 0 &&
|
value >= 0 &&
|
||||||
value <= maximum
|
value <= maximum
|
||||||
@@ -1274,23 +1460,29 @@ async function raceWithin<Value>(
|
|||||||
const timerController = new AbortController();
|
const timerController = new AbortController();
|
||||||
const onAbort = () => timerController.abort(signal.reason);
|
const onAbort = () => timerController.abort(signal.reason);
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
const workResult = work.then<TimedResult<Value>, TimedResult<Value>>(
|
try {
|
||||||
(value) => Object.freeze({ kind: "VALUE", value }),
|
const workResult = work.then<TimedResult<Value>, TimedResult<Value>>(
|
||||||
() => Object.freeze({ kind: "THREW" }),
|
(value) => Object.freeze({ kind: "VALUE", value }),
|
||||||
);
|
() => Object.freeze({ kind: "THREW" }),
|
||||||
const timerResult = clock.sleep(milliseconds, timerController.signal).then<
|
);
|
||||||
TimedResult<Value>,
|
// RPC-RR-02. A clock is an external collaborator: calling `sleep` inside a
|
||||||
TimedResult<Value>
|
// promise boundary turns its synchronous throw into a normalized rejection
|
||||||
>(
|
// instead of an exception that escapes the Result contract and skips the
|
||||||
() => Object.freeze({ kind: "TIMEOUT" }),
|
// listener and timer release below.
|
||||||
() => Object.freeze({ kind: "ABORTED" }),
|
const timerResult = Promise.resolve()
|
||||||
);
|
.then(() => clock.sleep(milliseconds, timerController.signal))
|
||||||
const selected = await Promise.race([workResult, timerResult]);
|
.then<TimedResult<Value>, TimedResult<Value>>(
|
||||||
timerController.abort("race-complete");
|
() => Object.freeze({ kind: "TIMEOUT" }),
|
||||||
signal.removeEventListener("abort", onAbort);
|
() => Object.freeze({ kind: "ABORTED" }),
|
||||||
return signal.aborted && selected.kind === "VALUE"
|
);
|
||||||
? Object.freeze({ kind: "ABORTED" })
|
const selected = await Promise.race([workResult, timerResult]);
|
||||||
: selected;
|
return signal.aborted && selected.kind === "VALUE"
|
||||||
|
? Object.freeze({ kind: "ABORTED" })
|
||||||
|
: selected;
|
||||||
|
} finally {
|
||||||
|
timerController.abort("race-complete");
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function observe(
|
function observe(
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
createReadOnlyRegistry,
|
||||||
|
type ReadOnlyRegistry,
|
||||||
|
} from "./read-only-registry.ts";
|
||||||
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
|
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
|
||||||
import type { RuntimeSchemaCodec } from "./schema-registry.ts";
|
import type { RuntimeSchemaCodec } from "./schema-registry.ts";
|
||||||
|
|
||||||
@@ -327,13 +331,18 @@ export function composeBrowserRpcRequestEncoderRegistry(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC-RR-03. Read facades, never `Map`s. `Object.freeze(new Map(...))` leaves
|
||||||
|
* `set`, `delete` and `clear` working, so an installed registry could still be
|
||||||
|
* emptied or re-pointed after the snapshot was validated.
|
||||||
|
*/
|
||||||
export type InstalledBrowserRpcContractBindings = Readonly<{
|
export type InstalledBrowserRpcContractBindings = Readonly<{
|
||||||
operations: ReadonlyMap<string, BrowserRpcOperationV3>;
|
operations: ReadOnlyRegistry<string, BrowserRpcOperationV3>;
|
||||||
profiles: ReadonlyMap<string, BrowserRpcProviderProfile>;
|
profiles: ReadOnlyRegistry<string, BrowserRpcProviderProfile>;
|
||||||
schemaCodecs: ReadonlyMap<string, RuntimeSchemaCodec>;
|
schemaCodecs: ReadOnlyRegistry<string, RuntimeSchemaCodec>;
|
||||||
mappers: ReadonlyMap<string, InstalledBoundaryMapper>;
|
mappers: ReadOnlyRegistry<string, InstalledBoundaryMapper>;
|
||||||
requestEncoders: ReadonlyMap<string, BrowserRpcRequestEncoder>;
|
requestEncoders: ReadOnlyRegistry<string, BrowserRpcRequestEncoder>;
|
||||||
runtimeBindings: ReadonlyMap<string, BrowserRpcRuntimeBindingIdentity>;
|
runtimeBindings: ReadOnlyRegistry<string, BrowserRpcRuntimeBindingIdentity>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,7 +360,7 @@ function installRegistrySnapshot<Value extends object>(
|
|||||||
source: Readonly<Record<string, Value>>,
|
source: Readonly<Record<string, Value>>,
|
||||||
label: string,
|
label: string,
|
||||||
allowedKeys: readonly string[],
|
allowedKeys: readonly string[],
|
||||||
): ReadonlyMap<string, Value> {
|
): ReadOnlyRegistry<string, Value> {
|
||||||
let ownKeys: string[];
|
let ownKeys: string[];
|
||||||
let symbols: readonly symbol[];
|
let symbols: readonly symbol[];
|
||||||
try {
|
try {
|
||||||
@@ -376,7 +385,7 @@ function installRegistrySnapshot<Value extends object>(
|
|||||||
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
|
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Object.freeze(installed) as ReadonlyMap<string, Value>;
|
return createReadOnlyRegistry(installed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function installRowSnapshot<Value extends object>(
|
function installRowSnapshot<Value extends object>(
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBrowserRpcRuntime,
|
||||||
|
defineBrowserRpcTransport,
|
||||||
|
type BrowserRpcStreamFrame,
|
||||||
|
type BrowserRpcTransport,
|
||||||
|
} from "../../../src/adapters/browser-rpc/index.ts";
|
||||||
|
import { installBrowserRpcContractBindings } from "../../../src/contracts/browser-rpc.ts";
|
||||||
|
import {
|
||||||
|
MAPPERS,
|
||||||
|
SCHEMA_CODECS,
|
||||||
|
STREAM_ENCODER,
|
||||||
|
UNARY_ENCODER,
|
||||||
|
isResourceView,
|
||||||
|
streamOperation,
|
||||||
|
streamProfile,
|
||||||
|
unaryOperation,
|
||||||
|
unaryProfile,
|
||||||
|
} from "./fixture.ts";
|
||||||
|
|
||||||
|
function unaryRuntime(
|
||||||
|
transport: BrowserRpcTransport,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return createBrowserRpcRuntime({
|
||||||
|
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
||||||
|
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||||
|
schemaCodecs: SCHEMA_CODECS,
|
||||||
|
mappers: MAPPERS,
|
||||||
|
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||||
|
transports: { CONNECT_REFERENCE_UNARY: transport },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamingRuntime(
|
||||||
|
transport: BrowserRpcTransport,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return createBrowserRpcRuntime({
|
||||||
|
operations: { WATCH_RPC_RESOURCES: streamOperation() },
|
||||||
|
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||||
|
schemaCodecs: SCHEMA_CODECS,
|
||||||
|
mappers: MAPPERS,
|
||||||
|
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
||||||
|
transports: { CONNECT_REFERENCE_STREAM: transport },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unaryTransport(
|
||||||
|
invokeUnary: BrowserRpcTransport["invokeUnary"],
|
||||||
|
): BrowserRpcTransport {
|
||||||
|
return defineBrowserRpcTransport({
|
||||||
|
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||||
|
providerId: "REFERENCE_RPC",
|
||||||
|
protocol: "CONNECT_HTTP",
|
||||||
|
rpcKind: "UNARY",
|
||||||
|
invokeUnary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamTransport(
|
||||||
|
frames: readonly BrowserRpcStreamFrame[],
|
||||||
|
): BrowserRpcTransport {
|
||||||
|
return defineBrowserRpcTransport({
|
||||||
|
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||||
|
providerId: "REFERENCE_RPC",
|
||||||
|
protocol: "CONNECT_HTTP",
|
||||||
|
rpcKind: "SERVER_STREAM",
|
||||||
|
openServerStream: () => ({
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
for (const frame of frames) yield frame;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(
|
||||||
|
source: AsyncIterable<unknown>,
|
||||||
|
): Promise<readonly unknown[]> {
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
for await (const value of source) seen.push(value);
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC-RR-02. A collaborator that throws synchronously must not escape the
|
||||||
|
* Result contract. A generation fence captured outside the protected boundary
|
||||||
|
* and a `clock.sleep` invoked outside a promise boundary both did exactly that,
|
||||||
|
* and the second also skipped the listener and timer release.
|
||||||
|
*/
|
||||||
|
describe("RPC-RR-02 synchronous collaborator throws stay inside Result", () => {
|
||||||
|
const throwingFence = {
|
||||||
|
capture: () => {
|
||||||
|
throw new TypeError("fence exploded");
|
||||||
|
},
|
||||||
|
isCurrent: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("closes a unary call whose fence throws", async () => {
|
||||||
|
const runtime = unaryRuntime(
|
||||||
|
unaryTransport(async () => ({
|
||||||
|
ok: true,
|
||||||
|
message: { id: "a", name: "A" },
|
||||||
|
encodedBytes: 8,
|
||||||
|
})),
|
||||||
|
{ generationFence: throwingFence },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runtime
|
||||||
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||||
|
.execute({ resourceId: "resource-1" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a stream whose fence throws", async () => {
|
||||||
|
const runtime = streamingRuntime(
|
||||||
|
streamTransport([{ kind: "TERMINAL", ok: true }]),
|
||||||
|
{ generationFence: throwingFence },
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await collect(
|
||||||
|
runtime
|
||||||
|
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||||
|
.open({ topic: "resources" }),
|
||||||
|
);
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a unary call whose clock throws synchronously", async () => {
|
||||||
|
const runtime = unaryRuntime(
|
||||||
|
unaryTransport(() => new Promise(() => {})),
|
||||||
|
{
|
||||||
|
clock: {
|
||||||
|
now: () => 0,
|
||||||
|
sleep: () => {
|
||||||
|
throw new TypeError("clock exploded");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runtime
|
||||||
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||||
|
.execute({ resourceId: "resource-1" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC-RR-03. The runtime reads a validated snapshot, never the caller's
|
||||||
|
* objects: an accessor is refused without being invoked, and no installed
|
||||||
|
* registry exposes a mutator.
|
||||||
|
*/
|
||||||
|
describe("RPC-RR-03 transport and binding registries are snapshots", () => {
|
||||||
|
it("never invokes a transport accessor", () => {
|
||||||
|
let getterCalls = 0;
|
||||||
|
const hostile = {} as Record<string, unknown>;
|
||||||
|
Object.defineProperties(hostile, {
|
||||||
|
runtimeProfileId: {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => {
|
||||||
|
getterCalls += 1;
|
||||||
|
return "CONNECT_REFERENCE_UNARY";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providerId: { enumerable: true, value: "REFERENCE_RPC" },
|
||||||
|
protocol: { enumerable: true, value: "CONNECT_HTTP" },
|
||||||
|
rpcKind: { enumerable: true, value: "UNARY" },
|
||||||
|
invokeUnary: {
|
||||||
|
enumerable: true,
|
||||||
|
value: async () => ({ ok: true, message: {}, encodedBytes: 1 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
unaryRuntime(hostile as unknown as BrowserRpcTransport),
|
||||||
|
).toThrow(TypeError);
|
||||||
|
expect(getterCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unexpected own field on a transport row", () => {
|
||||||
|
const transport = unaryTransport(async () => ({
|
||||||
|
ok: true,
|
||||||
|
message: { id: "a", name: "A" },
|
||||||
|
encodedBytes: 8,
|
||||||
|
}));
|
||||||
|
const widened = { ...transport, injected: true };
|
||||||
|
expect(() =>
|
||||||
|
unaryRuntime(widened as unknown as BrowserRpcTransport),
|
||||||
|
).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes no mutation API on any installed binding registry", () => {
|
||||||
|
const installed = installBrowserRpcContractBindings({
|
||||||
|
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
||||||
|
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||||
|
schemaCodecs: SCHEMA_CODECS,
|
||||||
|
mappers: MAPPERS,
|
||||||
|
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||||
|
});
|
||||||
|
for (const registry of Object.values(installed)) {
|
||||||
|
const record = registry as unknown as Record<string, unknown>;
|
||||||
|
for (const mutator of ["set", "delete", "clear"]) {
|
||||||
|
expect(record[mutator]).toBeUndefined();
|
||||||
|
}
|
||||||
|
expect(() =>
|
||||||
|
Map.prototype.clear.call(registry as never),
|
||||||
|
).toThrow();
|
||||||
|
}
|
||||||
|
expect(installed.operations.get("GET_RPC_RESOURCE")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC-RR-04. Transport values are decoded, not adopted. `in` and a direct
|
||||||
|
* property read run accessors and admit inherited or extra fields, and keeping
|
||||||
|
* the caller's object lets it change after validation.
|
||||||
|
*/
|
||||||
|
describe("RPC-RR-04 transport results and frames are exactly decoded", () => {
|
||||||
|
const hostileResults: readonly (readonly [string, () => unknown])[] = [
|
||||||
|
["extra own field", () => ({ ok: true, message: {}, encodedBytes: 1, injected: 1 })],
|
||||||
|
[
|
||||||
|
"inherited fields",
|
||||||
|
() =>
|
||||||
|
Object.create({ ok: true, message: {}, encodedBytes: 1 }) as object,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"throwing getter",
|
||||||
|
() => {
|
||||||
|
const value: Record<string, unknown> = { message: {}, encodedBytes: 1 };
|
||||||
|
Object.defineProperty(value, "ok", {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => {
|
||||||
|
throw new TypeError("hostile getter");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"symbol key",
|
||||||
|
() => ({
|
||||||
|
ok: true,
|
||||||
|
message: {},
|
||||||
|
encodedBytes: 1,
|
||||||
|
[Symbol("injected")]: 1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, build] of hostileResults) {
|
||||||
|
it(`refuses a unary result with ${label}`, async () => {
|
||||||
|
const runtime = unaryRuntime(
|
||||||
|
unaryTransport(async () => build() as never),
|
||||||
|
);
|
||||||
|
const result = await runtime
|
||||||
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||||
|
.execute({ resourceId: "resource-1" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("refuses an unknown transport failure code", async () => {
|
||||||
|
const runtime = unaryRuntime(
|
||||||
|
unaryTransport(async () => ({
|
||||||
|
ok: false,
|
||||||
|
failure: { code: "MADE_UP_CODE" },
|
||||||
|
}) as never),
|
||||||
|
);
|
||||||
|
const result = await runtime
|
||||||
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||||
|
.execute({ resourceId: "resource-1" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a retryAfterMs outside the hard ceiling", async () => {
|
||||||
|
const runtime = unaryRuntime(
|
||||||
|
unaryTransport(async () => ({
|
||||||
|
ok: false,
|
||||||
|
failure: { code: "NETWORK_UNREACHABLE", retryAfterMs: -1 },
|
||||||
|
}) as never),
|
||||||
|
);
|
||||||
|
const result = await runtime
|
||||||
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||||
|
.execute({ resourceId: "resource-1" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a stream frame with an extra own field", async () => {
|
||||||
|
const runtime = streamingRuntime(
|
||||||
|
streamTransport([
|
||||||
|
{
|
||||||
|
kind: "MESSAGE",
|
||||||
|
message: { id: "a", name: "A" },
|
||||||
|
encodedBytes: 4,
|
||||||
|
injected: 1,
|
||||||
|
} as never,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const results = await collect(
|
||||||
|
runtime
|
||||||
|
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||||
|
.open({ topic: "resources" }),
|
||||||
|
);
|
||||||
|
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user