Files
clean-architecture-frontend…/src/contracts/browser-rpc.ts
T
DongHyeonkaandClaude Opus 5 aa8ac35600 fix: make Browser RPC and Realtime own the physical work they report on
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>
2026-08-15 01:25:48 +09:00

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;
}