A server stream's registration was pruned on any settled close receipt.
`waitClosed()` rejecting, throwing synchronously, or not returning a
promise at all was absorbed into a fulfilled `undefined`, so the runtime
opened a second physical stream for the same operation while the first was
still running against the server. Only a fulfilled, contract-shaped
receipt confirms closure now; every negative receipt keeps the operation
DRAINING.
Cleanup also read foreign state outside the result boundary. A throwing
iterator `return` accessor replaced the already selected timeout with a
native `TypeError` and skipped the rest of the teardown, and the exported
lease decoder threw on a hostile `Symbol.asyncIterator`. Both reads move
inside their own boundaries, and the positive-close subscription is
installed before any fallible cleanup.
Composition validated the caller's registries before snapshotting them, so
a hostile accessor ran twice during validation, and rows hiding fields
behind a prototype or a non-enumerable key installed. Transport results
were checked for allowed own keys only, so own `{ok,message,encodedBytes}`
plus a prototype `injected` was a success and a missing `message` reached
a permissive schema as `undefined`.
In Realtime the tracked task was registered after the collaborator
returned. An authority that re-entered `close()` from inside its own
invocation saw an empty registry and got `{ok:true}` while its effect was
pending. The task is now registered first and the collaborator is invoked
a microtask later. A `scheduleTimeout` that threw was worse: the caller's
own catch treated it as an apply failure and started a recovery beside the
still-running effect, and `close()` rejected with a native `TypeError`. An
uninstallable deadline now fails closed as an expired one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
980 lines
32 KiB
TypeScript
980 lines
32 KiB
TypeScript
import {
|
|
createReadOnlyRegistry,
|
|
type ReadOnlyRegistry,
|
|
} from "./read-only-registry.ts";
|
|
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
|
|
import type { RuntimeSchemaCodec } from "./schema-registry.ts";
|
|
|
|
export const BROWSER_RPC_CONTRACT_VERSION = 3 as const;
|
|
|
|
export const BROWSER_RPC_HARD_LIMITS = Object.freeze({
|
|
maxRequestMessageBytes: 8 * 1024 * 1024,
|
|
maxResponseMessageBytes: 8 * 1024 * 1024,
|
|
maxTotalResponseBytes: 64 * 1024 * 1024,
|
|
maxBufferedBytes: 16 * 1024 * 1024,
|
|
maxResponseMessages: 10_000,
|
|
maxDeadlineMs: 30 * 60_000,
|
|
maxAttempts: 4,
|
|
maxBackoffMs: 30_000,
|
|
maxRetryAfterMs: 60_000,
|
|
maxProfiles: 128,
|
|
maxOperations: 512,
|
|
});
|
|
|
|
export type BrowserRpcProtocol = "CONNECT_HTTP" | "GRPC_WEB";
|
|
export type BrowserRpcRuntimeKind =
|
|
| "CONNECT_WEB_FETCH"
|
|
| "OFFICIAL_GRPC_WEB_XHR"
|
|
| "CUSTOM_FETCH_FRAMED";
|
|
export type BrowserRpcClientApiKind =
|
|
| "PROMISE_UNARY"
|
|
| "ASYNC_ITERABLE"
|
|
| "CALLBACK_STREAM";
|
|
export type BrowserRpcKind = "UNARY" | "SERVER_STREAM";
|
|
export type BrowserRpcMessageEncoding = "PROTO" | "JSON";
|
|
export type BrowserRpcFraming =
|
|
| "CONNECT_BARE"
|
|
| "CONNECT_ENVELOPE"
|
|
| "GRPC_WEB_BINARY_ENVELOPE"
|
|
| "GRPC_WEB_BASE64_TEXT";
|
|
export type BrowserRpcRequestMethod = "POST" | "GET";
|
|
export type BrowserRpcSemantics = "QUERY" | "COMMAND" | "SERVER_STREAM";
|
|
export type BrowserRpcReplayPolicy =
|
|
| "SAFE"
|
|
| "IDEMPOTENT"
|
|
| "KEYED_COMMAND"
|
|
| "NON_REPLAYABLE";
|
|
export type BrowserRpcIdempotencyLevel =
|
|
| "NONE"
|
|
| "IDEMPOTENT"
|
|
| "NO_SIDE_EFFECTS";
|
|
export type BrowserRpcDataClassification =
|
|
| "PUBLIC"
|
|
| "INTERNAL"
|
|
| "CONFIDENTIAL";
|
|
export type BrowserRpcRetryOwner =
|
|
| "FRONTEND_ADAPTER"
|
|
| "EDGE_PROXY"
|
|
| "NONE";
|
|
export type BrowserRpcRawByteCeilingOwner =
|
|
| "EDGE_PROXY"
|
|
| "BOUNDED_TRANSPORT"
|
|
| "EDGE_AND_TRANSPORT";
|
|
export type BrowserRpcDeadlineDialect =
|
|
| "CONNECT_TIMEOUT_MS"
|
|
| "GRPC_TIMEOUT"
|
|
| "OFFICIAL_DEADLINE_METADATA";
|
|
export type BrowserRpcCancelDialect =
|
|
| "ABORT_SIGNAL"
|
|
| "CLIENT_READABLE_STREAM_CANCEL";
|
|
export type BrowserRpcTransportFailureCode =
|
|
| "NETWORK_UNREACHABLE"
|
|
| "CANCELED"
|
|
| "DEADLINE_EXCEEDED"
|
|
| "UNAUTHENTICATED"
|
|
| "PERMISSION_DENIED"
|
|
| "NOT_FOUND"
|
|
| "ALREADY_EXISTS"
|
|
| "ABORTED"
|
|
| "FAILED_PRECONDITION"
|
|
| "INVALID_ARGUMENT"
|
|
| "RESOURCE_EXHAUSTED"
|
|
| "UNAVAILABLE"
|
|
| "UNIMPLEMENTED"
|
|
| "INTERNAL"
|
|
| "DATA_LOSS"
|
|
| "PROTOCOL_MISMATCH"
|
|
| "MESSAGE_LIMIT";
|
|
|
|
export type BrowserRpcOperationV3 = Readonly<{
|
|
contractVersion: typeof BROWSER_RPC_CONTRACT_VERSION;
|
|
operationId: string;
|
|
owner: string;
|
|
protocol: BrowserRpcProtocol;
|
|
semantics: BrowserRpcSemantics;
|
|
replayPolicy: BrowserRpcReplayPolicy;
|
|
idempotencyKeyPolicy: "NONE" | "REQUIRED";
|
|
idempotencyLevel: BrowserRpcIdempotencyLevel;
|
|
dataClassification: BrowserRpcDataClassification;
|
|
runtimeProfileId: string;
|
|
providerId: string;
|
|
fullyQualifiedService: string;
|
|
method: string;
|
|
rpcKind: BrowserRpcKind;
|
|
requestMessageId: string;
|
|
responseMessageId: string;
|
|
descriptorArtifactId: string;
|
|
descriptorDigest: string;
|
|
requestSchemaId: string;
|
|
responseSchemaId: string;
|
|
requestEncoderId: string;
|
|
mapperId: string;
|
|
authProfileId: string;
|
|
csrfProfileId: string;
|
|
errorProfileId: string;
|
|
deadlineProfileId: string;
|
|
retryProfileId: string;
|
|
serverStateProfileId: string | null;
|
|
maxRequestMessageBytes: number;
|
|
maxResponseMessageBytes: number;
|
|
maxResponseMessages: number;
|
|
maxTotalResponseBytes: number;
|
|
maxBufferedBytes: number;
|
|
idleDeadlineMs: number | null;
|
|
totalDeadlineMs: number;
|
|
}>;
|
|
|
|
export type BrowserRpcProviderProfile = Readonly<{
|
|
runtimeProfileId: string;
|
|
providerId: string;
|
|
fixedBaseUrl: string;
|
|
runtimeId: string;
|
|
runtimeVersion: string;
|
|
runtimeDigest: string;
|
|
protocol: BrowserRpcProtocol;
|
|
runtimeKind: BrowserRpcRuntimeKind;
|
|
clientApiKind: BrowserRpcClientApiKind;
|
|
rpcKind: BrowserRpcKind;
|
|
messageEncoding: BrowserRpcMessageEncoding;
|
|
framing: BrowserRpcFraming;
|
|
requestMethod: BrowserRpcRequestMethod;
|
|
descriptorArtifactId: string;
|
|
descriptorDigest: string;
|
|
allowedProcedures: readonly string[];
|
|
authProfileId: string;
|
|
csrfProfileId: string;
|
|
corsProfileId: string;
|
|
errorProfileId: string;
|
|
deadlineProfileId: string;
|
|
retryProfileId: string;
|
|
retryOwner: BrowserRpcRetryOwner;
|
|
maxAttempts: number;
|
|
backoffMs: readonly number[];
|
|
retryableFailures: readonly BrowserRpcTransportFailureCode[];
|
|
maxRetryAfterMs: number;
|
|
deadlineDialect: BrowserRpcDeadlineDialect;
|
|
cancelDialect: BrowserRpcCancelDialect;
|
|
rawByteCeilingOwner: BrowserRpcRawByteCeilingOwner;
|
|
streamMessageCompression: "IDENTITY_ONLY";
|
|
}>;
|
|
|
|
export type BrowserRpcRequestEncoder = Readonly<{
|
|
encoderId: string;
|
|
operationId: string;
|
|
encode(value: unknown):
|
|
| Readonly<{ ok: true; value: unknown; encodedBytes: number }>
|
|
| Readonly<{ ok: false; code: string }>;
|
|
}>;
|
|
|
|
export type BrowserRpcRuntimeBindingIdentity = Readonly<{
|
|
runtimeProfileId: string;
|
|
providerId: string;
|
|
protocol: BrowserRpcProtocol;
|
|
rpcKind: BrowserRpcKind;
|
|
}>;
|
|
|
|
export type BrowserRpcContractBindings = Readonly<{
|
|
operations: Readonly<Record<string, BrowserRpcOperationV3>>;
|
|
profiles: Readonly<Record<string, BrowserRpcProviderProfile>>;
|
|
schemaCodecs: Readonly<Record<string, RuntimeSchemaCodec>>;
|
|
mappers: Readonly<Record<string, InstalledBoundaryMapper>>;
|
|
requestEncoders: Readonly<Record<string, BrowserRpcRequestEncoder>>;
|
|
runtimeBindings?: Readonly<Record<string, BrowserRpcRuntimeBindingIdentity>>;
|
|
}>;
|
|
|
|
const REGISTRY_ID = /^[A-Z][A-Z0-9_]{2,79}$/;
|
|
const ARTIFACT_ID = /^[A-Za-z][A-Za-z0-9_.:-]{2,159}$/;
|
|
const OWNER = /^[a-z][a-z0-9-]{2,159}$/;
|
|
const SERVICE =
|
|
/^(?:[a-z][a-z0-9_]*\.)+[A-Z][A-Za-z0-9_]{1,79}$/;
|
|
const METHOD = /^[A-Z][A-Za-z0-9_]{1,79}$/;
|
|
const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,79}$/;
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const RETRYABLE_FAILURES = new Set<BrowserRpcTransportFailureCode>([
|
|
"NETWORK_UNREACHABLE",
|
|
"RESOURCE_EXHAUSTED",
|
|
"UNAVAILABLE",
|
|
]);
|
|
const PROTOCOLS = new Set<string>(["CONNECT_HTTP", "GRPC_WEB"]);
|
|
const RUNTIME_KINDS = new Set<string>([
|
|
"CONNECT_WEB_FETCH",
|
|
"OFFICIAL_GRPC_WEB_XHR",
|
|
"CUSTOM_FETCH_FRAMED",
|
|
]);
|
|
const CLIENT_API_KINDS = new Set<string>([
|
|
"PROMISE_UNARY",
|
|
"ASYNC_ITERABLE",
|
|
"CALLBACK_STREAM",
|
|
]);
|
|
const RPC_KINDS = new Set<string>(["UNARY", "SERVER_STREAM"]);
|
|
const MESSAGE_ENCODINGS = new Set<string>(["PROTO", "JSON"]);
|
|
const FRAMINGS = new Set<string>([
|
|
"CONNECT_BARE",
|
|
"CONNECT_ENVELOPE",
|
|
"GRPC_WEB_BINARY_ENVELOPE",
|
|
"GRPC_WEB_BASE64_TEXT",
|
|
]);
|
|
const REQUEST_METHODS = new Set<string>(["POST", "GET"]);
|
|
const SEMANTICS = new Set<string>([
|
|
"QUERY",
|
|
"COMMAND",
|
|
"SERVER_STREAM",
|
|
]);
|
|
const REPLAY_POLICIES = new Set<string>([
|
|
"SAFE",
|
|
"IDEMPOTENT",
|
|
"KEYED_COMMAND",
|
|
"NON_REPLAYABLE",
|
|
]);
|
|
const IDEMPOTENCY_KEY_POLICIES = new Set<string>(["NONE", "REQUIRED"]);
|
|
const IDEMPOTENCY_LEVELS = new Set<string>([
|
|
"NONE",
|
|
"IDEMPOTENT",
|
|
"NO_SIDE_EFFECTS",
|
|
]);
|
|
const DATA_CLASSIFICATIONS = new Set<string>([
|
|
"PUBLIC",
|
|
"INTERNAL",
|
|
"CONFIDENTIAL",
|
|
]);
|
|
const RETRY_OWNERS = new Set<string>([
|
|
"FRONTEND_ADAPTER",
|
|
"EDGE_PROXY",
|
|
"NONE",
|
|
]);
|
|
const DEADLINE_DIALECTS = new Set<string>([
|
|
"CONNECT_TIMEOUT_MS",
|
|
"GRPC_TIMEOUT",
|
|
"OFFICIAL_DEADLINE_METADATA",
|
|
]);
|
|
const CANCEL_DIALECTS = new Set<string>([
|
|
"ABORT_SIGNAL",
|
|
"CLIENT_READABLE_STREAM_CANCEL",
|
|
]);
|
|
const RAW_BYTE_CEILING_OWNERS = new Set<string>([
|
|
"EDGE_PROXY",
|
|
"BOUNDED_TRANSPORT",
|
|
"EDGE_AND_TRANSPORT",
|
|
]);
|
|
|
|
export function defineBrowserRpcOperation(
|
|
operation: BrowserRpcOperationV3,
|
|
): BrowserRpcOperationV3 {
|
|
validateOperation(operation);
|
|
return Object.freeze({ ...operation });
|
|
}
|
|
|
|
export function defineBrowserRpcProviderProfile(
|
|
profile: BrowserRpcProviderProfile,
|
|
): BrowserRpcProviderProfile {
|
|
validateProviderProfile(profile);
|
|
return Object.freeze({
|
|
...profile,
|
|
allowedProcedures: Object.freeze([...profile.allowedProcedures]),
|
|
backoffMs: Object.freeze([...profile.backoffMs]),
|
|
retryableFailures: Object.freeze([...profile.retryableFailures]),
|
|
});
|
|
}
|
|
|
|
export function defineBrowserRpcRequestEncoder(
|
|
encoder: BrowserRpcRequestEncoder,
|
|
): BrowserRpcRequestEncoder {
|
|
if (
|
|
!ARTIFACT_ID.test(encoder.encoderId) ||
|
|
!REGISTRY_ID.test(encoder.operationId) ||
|
|
typeof encoder.encode !== "function"
|
|
) {
|
|
throw new TypeError("Browser RPC request encoder is invalid.");
|
|
}
|
|
return Object.freeze({ ...encoder });
|
|
}
|
|
|
|
export function composeBrowserRpcOperationRegistry(
|
|
contributions: readonly Readonly<
|
|
Record<string, BrowserRpcOperationV3>
|
|
>[],
|
|
): Readonly<Record<string, BrowserRpcOperationV3>> {
|
|
return composeRegistry(
|
|
contributions,
|
|
(operation) => operation.operationId,
|
|
defineBrowserRpcOperation,
|
|
"operation",
|
|
BROWSER_RPC_HARD_LIMITS.maxOperations,
|
|
);
|
|
}
|
|
|
|
export function composeBrowserRpcProviderProfileRegistry(
|
|
contributions: readonly Readonly<
|
|
Record<string, BrowserRpcProviderProfile>
|
|
>[],
|
|
): Readonly<Record<string, BrowserRpcProviderProfile>> {
|
|
return composeRegistry(
|
|
contributions,
|
|
(profile) => profile.runtimeProfileId,
|
|
defineBrowserRpcProviderProfile,
|
|
"provider profile",
|
|
BROWSER_RPC_HARD_LIMITS.maxProfiles,
|
|
);
|
|
}
|
|
|
|
export function composeBrowserRpcRequestEncoderRegistry(
|
|
contributions: readonly Readonly<
|
|
Record<string, BrowserRpcRequestEncoder>
|
|
>[],
|
|
): Readonly<Record<string, BrowserRpcRequestEncoder>> {
|
|
return composeRegistry(
|
|
contributions,
|
|
(encoder) => encoder.encoderId,
|
|
defineBrowserRpcRequestEncoder,
|
|
"request encoder",
|
|
BROWSER_RPC_HARD_LIMITS.maxOperations,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<{
|
|
operations: ReadOnlyRegistry<string, BrowserRpcOperationV3>;
|
|
profiles: ReadOnlyRegistry<string, BrowserRpcProviderProfile>;
|
|
schemaCodecs: ReadOnlyRegistry<string, RuntimeSchemaCodec>;
|
|
mappers: ReadOnlyRegistry<string, InstalledBoundaryMapper>;
|
|
requestEncoders: ReadOnlyRegistry<string, BrowserRpcRequestEncoder>;
|
|
runtimeBindings: ReadOnlyRegistry<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[],
|
|
): ReadOnlyRegistry<string, Value> {
|
|
let ownKeys: string[];
|
|
let symbols: readonly symbol[];
|
|
let prototype: object | null;
|
|
try {
|
|
// RPC-03. Own *names*, not just enumerable keys: a non-enumerable own entry
|
|
// is as much a smuggled row as an inherited one, and `Object.keys` never
|
|
// saw either.
|
|
ownKeys = Object.getOwnPropertyNames(source);
|
|
symbols = Object.getOwnPropertySymbols(source);
|
|
prototype = Reflect.getPrototypeOf(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.`);
|
|
}
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
throw new TypeError(`Browser RPC ${label} registry has a custom prototype.`);
|
|
}
|
|
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 createReadOnlyRegistry(installed);
|
|
}
|
|
|
|
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[];
|
|
let prototype: object | null;
|
|
try {
|
|
ownKeys = Object.getOwnPropertyNames(row);
|
|
symbols = Object.getOwnPropertySymbols(row);
|
|
prototype = Reflect.getPrototypeOf(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.`);
|
|
}
|
|
// RPC-03. A custom prototype carries fields the name sweep never sees and
|
|
// stays live after installation, so the installed row would not be the row
|
|
// that was checked.
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
throw new TypeError(`Browser RPC ${label} row has a custom prototype.`);
|
|
}
|
|
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 {
|
|
for (const [profileId, profile] of Object.entries(bindings.profiles)) {
|
|
if (profileId !== profile.runtimeProfileId) {
|
|
throw new TypeError(
|
|
`Browser RPC provider profile registry is invalid: ${profileId}`,
|
|
);
|
|
}
|
|
validateProviderProfile(profile);
|
|
}
|
|
for (const [schemaId, schema] of Object.entries(bindings.schemaCodecs)) {
|
|
if (schemaId !== schema.schemaId || typeof schema.parse !== "function") {
|
|
throw new TypeError(
|
|
`Browser RPC schema registry is invalid: ${schemaId}`,
|
|
);
|
|
}
|
|
}
|
|
for (const [mapperId, mapper] of Object.entries(bindings.mappers)) {
|
|
if (
|
|
mapperId !== mapper.mapperId ||
|
|
typeof mapper.map !== "function" ||
|
|
!Number.isSafeInteger(mapper.mapperVersion) ||
|
|
mapper.mapperVersion < 1
|
|
) {
|
|
throw new TypeError(
|
|
`Browser RPC mapper registry is invalid: ${mapperId}`,
|
|
);
|
|
}
|
|
}
|
|
for (const [encoderId, encoder] of Object.entries(
|
|
bindings.requestEncoders,
|
|
)) {
|
|
if (encoderId !== encoder.encoderId) {
|
|
throw new TypeError(
|
|
`Browser RPC request encoder registry is invalid: ${encoderId}`,
|
|
);
|
|
}
|
|
defineBrowserRpcRequestEncoder(encoder);
|
|
}
|
|
for (const [profileId, runtime] of Object.entries(
|
|
bindings.runtimeBindings ?? {},
|
|
)) {
|
|
if (
|
|
profileId !== runtime.runtimeProfileId ||
|
|
!REGISTRY_ID.test(runtime.runtimeProfileId) ||
|
|
!REGISTRY_ID.test(runtime.providerId) ||
|
|
!PROTOCOLS.has(runtime.protocol) ||
|
|
!RPC_KINDS.has(runtime.rpcKind)
|
|
) {
|
|
throw new TypeError(
|
|
`Browser RPC runtime registry is invalid: ${profileId}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const [operationId, operation] of Object.entries(
|
|
bindings.operations,
|
|
)) {
|
|
if (operationId !== operation.operationId) {
|
|
throw new TypeError(
|
|
`Browser RPC operation registry is invalid: ${operationId}`,
|
|
);
|
|
}
|
|
validateOperation(operation);
|
|
const profile = bindings.profiles[operation.runtimeProfileId];
|
|
const requestSchema = bindings.schemaCodecs[operation.requestSchemaId];
|
|
const responseSchema = bindings.schemaCodecs[operation.responseSchemaId];
|
|
const mapper = bindings.mappers[operation.mapperId];
|
|
const encoder = bindings.requestEncoders[operation.requestEncoderId];
|
|
const runtime = bindings.runtimeBindings?.[operation.runtimeProfileId];
|
|
const procedure = `${operation.fullyQualifiedService}/${operation.method}`;
|
|
|
|
if (
|
|
!profile ||
|
|
profile.providerId !== operation.providerId ||
|
|
profile.protocol !== operation.protocol ||
|
|
profile.rpcKind !== operation.rpcKind ||
|
|
profile.descriptorArtifactId !== operation.descriptorArtifactId ||
|
|
profile.descriptorDigest !== operation.descriptorDigest ||
|
|
profile.authProfileId !== operation.authProfileId ||
|
|
profile.csrfProfileId !== operation.csrfProfileId ||
|
|
profile.errorProfileId !== operation.errorProfileId ||
|
|
profile.deadlineProfileId !== operation.deadlineProfileId ||
|
|
profile.retryProfileId !== operation.retryProfileId ||
|
|
!profile.allowedProcedures.includes(procedure)
|
|
) {
|
|
throw new TypeError(
|
|
`Browser RPC provider binding is invalid: ${operation.operationId}`,
|
|
);
|
|
}
|
|
if (
|
|
requestSchema?.schemaId !== operation.requestSchemaId ||
|
|
responseSchema?.schemaId !== operation.responseSchemaId ||
|
|
mapper?.mapperId !== operation.mapperId ||
|
|
mapper.inputSchemaId !== operation.responseSchemaId ||
|
|
mapper.maxOutputItems < 1 ||
|
|
encoder?.encoderId !== operation.requestEncoderId ||
|
|
encoder.operationId !== operation.operationId
|
|
) {
|
|
throw new TypeError(
|
|
`Browser RPC schema/mapper binding is invalid: ${operation.operationId}`,
|
|
);
|
|
}
|
|
if (
|
|
runtime &&
|
|
(runtime.runtimeProfileId !== operation.runtimeProfileId ||
|
|
runtime.providerId !== operation.providerId ||
|
|
runtime.protocol !== operation.protocol ||
|
|
runtime.rpcKind !== operation.rpcKind)
|
|
) {
|
|
throw new TypeError(
|
|
`Browser RPC runtime binding is invalid: ${operation.operationId}`,
|
|
);
|
|
}
|
|
if (
|
|
profile.requestMethod === "GET" &&
|
|
(operation.protocol !== "CONNECT_HTTP" ||
|
|
operation.rpcKind !== "UNARY" ||
|
|
operation.semantics !== "QUERY" ||
|
|
operation.replayPolicy !== "SAFE" ||
|
|
operation.idempotencyLevel !== "NO_SIDE_EFFECTS" ||
|
|
operation.dataClassification !== "PUBLIC" ||
|
|
operation.authProfileId !== "ANONYMOUS" ||
|
|
operation.csrfProfileId !== "NONE")
|
|
) {
|
|
throw new TypeError(
|
|
`Browser RPC GET binding is invalid: ${operation.operationId}`,
|
|
);
|
|
}
|
|
if (
|
|
profile.retryOwner === "FRONTEND_ADAPTER" &&
|
|
!isFrontendReplayAllowed(operation)
|
|
) {
|
|
throw new TypeError(
|
|
`Browser RPC retry binding is invalid: ${operation.operationId}`,
|
|
);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function validateOperation(operation: BrowserRpcOperationV3): void {
|
|
if (
|
|
operation.contractVersion !== BROWSER_RPC_CONTRACT_VERSION ||
|
|
!REGISTRY_ID.test(operation.operationId) ||
|
|
!OWNER.test(operation.owner) ||
|
|
!PROTOCOLS.has(operation.protocol) ||
|
|
!SEMANTICS.has(operation.semantics) ||
|
|
!REPLAY_POLICIES.has(operation.replayPolicy) ||
|
|
!IDEMPOTENCY_KEY_POLICIES.has(operation.idempotencyKeyPolicy) ||
|
|
!IDEMPOTENCY_LEVELS.has(operation.idempotencyLevel) ||
|
|
!DATA_CLASSIFICATIONS.has(operation.dataClassification) ||
|
|
!REGISTRY_ID.test(operation.runtimeProfileId) ||
|
|
!REGISTRY_ID.test(operation.providerId) ||
|
|
!SERVICE.test(operation.fullyQualifiedService) ||
|
|
!METHOD.test(operation.method) ||
|
|
!RPC_KINDS.has(operation.rpcKind) ||
|
|
!ARTIFACT_ID.test(operation.requestMessageId) ||
|
|
!ARTIFACT_ID.test(operation.responseMessageId) ||
|
|
!ARTIFACT_ID.test(operation.descriptorArtifactId) ||
|
|
!SHA256.test(operation.descriptorDigest) ||
|
|
!ARTIFACT_ID.test(operation.requestSchemaId) ||
|
|
!ARTIFACT_ID.test(operation.responseSchemaId) ||
|
|
!ARTIFACT_ID.test(operation.requestEncoderId) ||
|
|
!ARTIFACT_ID.test(operation.mapperId) ||
|
|
!REGISTRY_ID.test(operation.authProfileId) ||
|
|
!REGISTRY_ID.test(operation.csrfProfileId) ||
|
|
!REGISTRY_ID.test(operation.errorProfileId) ||
|
|
!REGISTRY_ID.test(operation.deadlineProfileId) ||
|
|
!REGISTRY_ID.test(operation.retryProfileId) ||
|
|
(operation.serverStateProfileId !== null &&
|
|
!ARTIFACT_ID.test(operation.serverStateProfileId)) ||
|
|
!positiveIntegerWithin(
|
|
operation.maxRequestMessageBytes,
|
|
BROWSER_RPC_HARD_LIMITS.maxRequestMessageBytes,
|
|
) ||
|
|
!positiveIntegerWithin(
|
|
operation.maxResponseMessageBytes,
|
|
BROWSER_RPC_HARD_LIMITS.maxResponseMessageBytes,
|
|
) ||
|
|
!positiveIntegerWithin(
|
|
operation.maxResponseMessages,
|
|
BROWSER_RPC_HARD_LIMITS.maxResponseMessages,
|
|
) ||
|
|
!positiveIntegerWithin(
|
|
operation.maxTotalResponseBytes,
|
|
BROWSER_RPC_HARD_LIMITS.maxTotalResponseBytes,
|
|
) ||
|
|
!positiveIntegerWithin(
|
|
operation.maxBufferedBytes,
|
|
BROWSER_RPC_HARD_LIMITS.maxBufferedBytes,
|
|
) ||
|
|
!positiveIntegerWithin(
|
|
operation.totalDeadlineMs,
|
|
BROWSER_RPC_HARD_LIMITS.maxDeadlineMs,
|
|
) ||
|
|
operation.maxTotalResponseBytes < operation.maxResponseMessageBytes ||
|
|
operation.maxBufferedBytes < operation.maxResponseMessageBytes
|
|
) {
|
|
throw new TypeError(
|
|
`Invalid Browser RPC operation: ${operation.operationId}`,
|
|
);
|
|
}
|
|
|
|
const unary = operation.rpcKind === "UNARY";
|
|
if (
|
|
(unary &&
|
|
(operation.semantics === "SERVER_STREAM" ||
|
|
operation.maxResponseMessages !== 1 ||
|
|
operation.idleDeadlineMs !== null)) ||
|
|
(!unary &&
|
|
(operation.semantics !== "SERVER_STREAM" ||
|
|
!positiveIntegerWithin(
|
|
operation.idleDeadlineMs,
|
|
operation.totalDeadlineMs,
|
|
))) ||
|
|
(operation.semantics === "QUERY" &&
|
|
!["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy)) ||
|
|
(operation.semantics === "COMMAND" &&
|
|
["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy)) ||
|
|
(operation.replayPolicy === "KEYED_COMMAND" &&
|
|
operation.idempotencyKeyPolicy !== "REQUIRED") ||
|
|
(operation.replayPolicy !== "KEYED_COMMAND" &&
|
|
operation.idempotencyKeyPolicy !== "NONE") ||
|
|
(operation.idempotencyLevel === "NO_SIDE_EFFECTS" &&
|
|
operation.semantics !== "QUERY")
|
|
) {
|
|
throw new TypeError(
|
|
`Incoherent Browser RPC operation: ${operation.operationId}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateProviderProfile(profile: BrowserRpcProviderProfile): void {
|
|
let endpoint: URL;
|
|
try {
|
|
endpoint = new URL(profile.fixedBaseUrl);
|
|
} catch {
|
|
throw new TypeError("Browser RPC provider profile is invalid.");
|
|
}
|
|
const localHttp =
|
|
endpoint.protocol === "http:" &&
|
|
["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname);
|
|
if (
|
|
!REGISTRY_ID.test(profile.runtimeProfileId) ||
|
|
!REGISTRY_ID.test(profile.providerId) ||
|
|
!ARTIFACT_ID.test(profile.runtimeId) ||
|
|
!VERSION.test(profile.runtimeVersion) ||
|
|
!SHA256.test(profile.runtimeDigest) ||
|
|
!PROTOCOLS.has(profile.protocol) ||
|
|
!RUNTIME_KINDS.has(profile.runtimeKind) ||
|
|
!CLIENT_API_KINDS.has(profile.clientApiKind) ||
|
|
!RPC_KINDS.has(profile.rpcKind) ||
|
|
!MESSAGE_ENCODINGS.has(profile.messageEncoding) ||
|
|
!FRAMINGS.has(profile.framing) ||
|
|
!REQUEST_METHODS.has(profile.requestMethod) ||
|
|
!ARTIFACT_ID.test(profile.descriptorArtifactId) ||
|
|
!SHA256.test(profile.descriptorDigest) ||
|
|
!REGISTRY_ID.test(profile.authProfileId) ||
|
|
!REGISTRY_ID.test(profile.csrfProfileId) ||
|
|
!REGISTRY_ID.test(profile.corsProfileId) ||
|
|
!REGISTRY_ID.test(profile.errorProfileId) ||
|
|
!REGISTRY_ID.test(profile.deadlineProfileId) ||
|
|
!REGISTRY_ID.test(profile.retryProfileId) ||
|
|
!RETRY_OWNERS.has(profile.retryOwner) ||
|
|
!DEADLINE_DIALECTS.has(profile.deadlineDialect) ||
|
|
!CANCEL_DIALECTS.has(profile.cancelDialect) ||
|
|
!RAW_BYTE_CEILING_OWNERS.has(profile.rawByteCeilingOwner) ||
|
|
profile.streamMessageCompression !== "IDENTITY_ONLY" ||
|
|
(endpoint.protocol !== "https:" && !localHttp) ||
|
|
endpoint.username ||
|
|
endpoint.password ||
|
|
endpoint.search ||
|
|
endpoint.hash ||
|
|
profile.allowedProcedures.length === 0 ||
|
|
profile.allowedProcedures.length > BROWSER_RPC_HARD_LIMITS.maxOperations ||
|
|
new Set(profile.allowedProcedures).size !==
|
|
profile.allowedProcedures.length ||
|
|
profile.allowedProcedures.some((procedure) => {
|
|
const separator = procedure.lastIndexOf("/");
|
|
return (
|
|
separator < 1 ||
|
|
!SERVICE.test(procedure.slice(0, separator)) ||
|
|
!METHOD.test(procedure.slice(separator + 1))
|
|
);
|
|
}) ||
|
|
!positiveIntegerWithin(
|
|
profile.maxAttempts,
|
|
BROWSER_RPC_HARD_LIMITS.maxAttempts,
|
|
) ||
|
|
!nonNegativeIntegerWithin(
|
|
profile.maxRetryAfterMs,
|
|
BROWSER_RPC_HARD_LIMITS.maxRetryAfterMs,
|
|
) ||
|
|
new Set(profile.retryableFailures).size !==
|
|
profile.retryableFailures.length ||
|
|
profile.retryableFailures.some(
|
|
(failure) => !RETRYABLE_FAILURES.has(failure),
|
|
) ||
|
|
profile.backoffMs.some(
|
|
(delay) =>
|
|
!nonNegativeIntegerWithin(
|
|
delay,
|
|
BROWSER_RPC_HARD_LIMITS.maxBackoffMs,
|
|
),
|
|
) ||
|
|
(profile.retryOwner === "FRONTEND_ADAPTER"
|
|
? profile.maxAttempts < 2 ||
|
|
profile.backoffMs.length !== profile.maxAttempts - 1 ||
|
|
profile.retryableFailures.length === 0
|
|
: profile.maxAttempts !== 1 ||
|
|
profile.backoffMs.length !== 0 ||
|
|
profile.retryableFailures.length !== 0) ||
|
|
(profile.rpcKind === "SERVER_STREAM" &&
|
|
profile.retryOwner !== "NONE") ||
|
|
!runtimeTupleIsValid(profile)
|
|
) {
|
|
throw new TypeError("Browser RPC provider profile is invalid.");
|
|
}
|
|
}
|
|
|
|
function runtimeTupleIsValid(profile: BrowserRpcProviderProfile): boolean {
|
|
if (profile.protocol === "CONNECT_HTTP") {
|
|
if (
|
|
profile.runtimeKind !== "CONNECT_WEB_FETCH" ||
|
|
profile.deadlineDialect !== "CONNECT_TIMEOUT_MS" ||
|
|
profile.cancelDialect !== "ABORT_SIGNAL"
|
|
) {
|
|
return false;
|
|
}
|
|
if (profile.rpcKind === "UNARY") {
|
|
return (
|
|
profile.clientApiKind === "PROMISE_UNARY" &&
|
|
profile.framing === "CONNECT_BARE"
|
|
);
|
|
}
|
|
return (
|
|
profile.requestMethod === "POST" &&
|
|
profile.clientApiKind === "ASYNC_ITERABLE" &&
|
|
profile.framing === "CONNECT_ENVELOPE"
|
|
);
|
|
}
|
|
|
|
if (profile.requestMethod !== "POST") return false;
|
|
if (profile.runtimeKind === "OFFICIAL_GRPC_WEB_XHR") {
|
|
if (
|
|
profile.messageEncoding !== "PROTO" ||
|
|
profile.deadlineDialect !== "OFFICIAL_DEADLINE_METADATA"
|
|
) {
|
|
return false;
|
|
}
|
|
if (profile.rpcKind === "UNARY") {
|
|
return (
|
|
["PROMISE_UNARY", "CALLBACK_STREAM"].includes(
|
|
profile.clientApiKind,
|
|
) &&
|
|
["GRPC_WEB_BINARY_ENVELOPE", "GRPC_WEB_BASE64_TEXT"].includes(
|
|
profile.framing,
|
|
) &&
|
|
(profile.clientApiKind === "CALLBACK_STREAM"
|
|
? profile.cancelDialect === "CLIENT_READABLE_STREAM_CANCEL"
|
|
: profile.cancelDialect === "ABORT_SIGNAL")
|
|
);
|
|
}
|
|
return (
|
|
profile.clientApiKind === "CALLBACK_STREAM" &&
|
|
profile.framing === "GRPC_WEB_BASE64_TEXT" &&
|
|
profile.cancelDialect === "CLIENT_READABLE_STREAM_CANCEL"
|
|
);
|
|
}
|
|
|
|
if (profile.runtimeKind === "CONNECT_WEB_FETCH") {
|
|
return (
|
|
profile.deadlineDialect === "GRPC_TIMEOUT" &&
|
|
profile.cancelDialect === "ABORT_SIGNAL" &&
|
|
profile.framing === "GRPC_WEB_BINARY_ENVELOPE" &&
|
|
(profile.rpcKind === "UNARY"
|
|
? profile.clientApiKind === "PROMISE_UNARY"
|
|
: profile.clientApiKind === "ASYNC_ITERABLE")
|
|
);
|
|
}
|
|
|
|
return (
|
|
profile.runtimeKind === "CUSTOM_FETCH_FRAMED" &&
|
|
profile.deadlineDialect === "GRPC_TIMEOUT" &&
|
|
profile.cancelDialect === "ABORT_SIGNAL" &&
|
|
(profile.rpcKind === "UNARY"
|
|
? profile.clientApiKind === "PROMISE_UNARY"
|
|
: profile.clientApiKind === "ASYNC_ITERABLE")
|
|
);
|
|
}
|
|
|
|
function isFrontendReplayAllowed(
|
|
operation: BrowserRpcOperationV3,
|
|
): boolean {
|
|
return (
|
|
["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy) ||
|
|
(operation.replayPolicy === "KEYED_COMMAND" &&
|
|
operation.idempotencyKeyPolicy === "REQUIRED")
|
|
);
|
|
}
|
|
|
|
function composeRegistry<Value>(
|
|
contributions: readonly Readonly<Record<string, Value>>[],
|
|
identity: (value: Value) => string,
|
|
define: (value: Value) => Value,
|
|
label: string,
|
|
maximumRows: number,
|
|
): Readonly<Record<string, Value>> {
|
|
const result: Record<string, Value> = Object.create(null);
|
|
let rows = 0;
|
|
for (const contribution of contributions) {
|
|
for (const [registryId, value] of Object.entries(contribution)) {
|
|
rows += 1;
|
|
if (
|
|
rows > maximumRows ||
|
|
registryId !== identity(value) ||
|
|
Object.hasOwn(result, registryId)
|
|
) {
|
|
throw new TypeError(
|
|
`Invalid or duplicate Browser RPC ${label}: ${registryId}`,
|
|
);
|
|
}
|
|
result[registryId] = define(value);
|
|
}
|
|
}
|
|
return Object.freeze(result);
|
|
}
|
|
|
|
function positiveIntegerWithin(
|
|
value: number | null,
|
|
maximum: number,
|
|
): value is number {
|
|
return Number.isSafeInteger(value) && value !== null && value > 0 && value <= maximum;
|
|
}
|
|
|
|
function nonNegativeIntegerWithin(
|
|
value: number,
|
|
maximum: number,
|
|
): boolean {
|
|
return Number.isSafeInteger(value) && value >= 0 && value <= maximum;
|
|
}
|