1146 lines
31 KiB
TypeScript
1146 lines
31 KiB
TypeScript
import type {
|
|
BrowserRpcGenerationFence,
|
|
BrowserRpcServerStreamPort,
|
|
BrowserRpcUnaryPort,
|
|
} from "../../application/ports/browser-rpc/index.ts";
|
|
import type { ClockPort } from "../../application/ports/clock-port.ts";
|
|
import type { Result } from "../../application/result.ts";
|
|
import {
|
|
BROWSER_RPC_HARD_LIMITS,
|
|
validateBrowserRpcContractBindings,
|
|
type BrowserRpcOperationV3,
|
|
type BrowserRpcProviderProfile,
|
|
type BrowserRpcRequestEncoder,
|
|
type BrowserRpcTransportFailureCode,
|
|
} from "../../contracts/browser-rpc.ts";
|
|
import type { InstalledBoundaryMapper } from "../../contracts/boundary-mapper.ts";
|
|
import {
|
|
createFailure,
|
|
type AppFailure,
|
|
type FailureKind,
|
|
} from "../../contracts/errors.ts";
|
|
import type { RuntimeSchemaCodec } from "../../contracts/schema-registry.ts";
|
|
import { systemClock } from "../platform/system-clock.ts";
|
|
import type {
|
|
BrowserRpcStreamFrame,
|
|
BrowserRpcTransport,
|
|
BrowserRpcTransportFailure,
|
|
BrowserRpcUnaryTransportResult,
|
|
} from "./transport.ts";
|
|
|
|
export type BrowserRpcObservationOutcome =
|
|
| "SUCCESS"
|
|
| "FAILED"
|
|
| "ABORTED"
|
|
| "TIMEOUT"
|
|
| "CONTRACT_REJECTED";
|
|
|
|
export type BrowserRpcObservation = Readonly<{
|
|
operationId: string;
|
|
protocol: "CONNECT_HTTP" | "GRPC_WEB";
|
|
runtimeProfileId: string;
|
|
rpcKind: "UNARY" | "SERVER_STREAM";
|
|
outcome: BrowserRpcObservationOutcome;
|
|
attemptCount: number;
|
|
messageCount: number;
|
|
}>;
|
|
|
|
export type BrowserRpcObservationSink = Readonly<{
|
|
observe(observation: BrowserRpcObservation): void;
|
|
}>;
|
|
|
|
export type BrowserRpcRuntimeDependencies = 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>>;
|
|
transports: Readonly<Record<string, BrowserRpcTransport>>;
|
|
generationFence?: BrowserRpcGenerationFence;
|
|
clock?: ClockPort;
|
|
observations?: BrowserRpcObservationSink;
|
|
}>;
|
|
|
|
export type BrowserRpcRuntime = Readonly<{
|
|
bindUnary<Input, Output>(
|
|
operationId: string,
|
|
isOutput: (value: unknown) => value is Output,
|
|
): BrowserRpcUnaryPort<Input, Output>;
|
|
bindServerStream<Input, Event>(
|
|
operationId: string,
|
|
isEvent: (value: unknown) => value is Event,
|
|
): BrowserRpcServerStreamPort<Input, Event>;
|
|
}>;
|
|
|
|
type BoundOperation = Readonly<{
|
|
operation: BrowserRpcOperationV3;
|
|
profile: BrowserRpcProviderProfile;
|
|
encoder: BrowserRpcRequestEncoder;
|
|
transport: BrowserRpcTransport;
|
|
}>;
|
|
|
|
type PreparedRequest =
|
|
| Readonly<{
|
|
ok: true;
|
|
value: unknown;
|
|
encodedBytes: number;
|
|
}>
|
|
| Readonly<{ ok: false; error: AppFailure }>;
|
|
|
|
type TimedResult<Value> =
|
|
| Readonly<{ kind: "VALUE"; value: Value }>
|
|
| Readonly<{ kind: "TIMEOUT" }>
|
|
| Readonly<{ kind: "ABORTED" }>
|
|
| Readonly<{ kind: "THREW" }>;
|
|
|
|
const stableGenerationFence: BrowserRpcGenerationFence<string> =
|
|
Object.freeze({
|
|
capture: () => "stable",
|
|
isCurrent: (token) => token === "stable",
|
|
});
|
|
|
|
export function createBrowserRpcRuntime(
|
|
dependencies: BrowserRpcRuntimeDependencies,
|
|
): BrowserRpcRuntime {
|
|
const clock = dependencies.clock ?? systemClock;
|
|
const generationFence =
|
|
dependencies.generationFence ?? stableGenerationFence;
|
|
validateRuntimeDependencies(dependencies);
|
|
|
|
function bind(
|
|
operationId: string,
|
|
expectedKind: "UNARY" | "SERVER_STREAM",
|
|
): BoundOperation {
|
|
const operation = dependencies.operations[operationId];
|
|
if (!operation || operation.rpcKind !== expectedKind) {
|
|
throw new TypeError(
|
|
`Browser RPC operation cannot be bound as ${expectedKind}: ${operationId}`,
|
|
);
|
|
}
|
|
const profile = dependencies.profiles[operation.runtimeProfileId];
|
|
const encoder = dependencies.requestEncoders[operation.requestEncoderId];
|
|
const transport = dependencies.transports[operation.runtimeProfileId];
|
|
if (!profile || !encoder || !transport) {
|
|
throw new TypeError(
|
|
`Browser RPC runtime binding is incomplete: ${operationId}`,
|
|
);
|
|
}
|
|
return Object.freeze({ operation, profile, encoder, transport });
|
|
}
|
|
|
|
return Object.freeze({
|
|
bindUnary<Input, Output>(
|
|
operationId: string,
|
|
isOutput: (value: unknown) => value is Output,
|
|
): BrowserRpcUnaryPort<Input, Output> {
|
|
if (typeof isOutput !== "function") {
|
|
throw new TypeError("Browser RPC unary result guard is required.");
|
|
}
|
|
const bound = bind(operationId, "UNARY");
|
|
return Object.freeze({
|
|
execute: (input, context = {}) =>
|
|
executeUnary(
|
|
dependencies,
|
|
bound,
|
|
input,
|
|
context,
|
|
isOutput,
|
|
generationFence,
|
|
clock,
|
|
),
|
|
});
|
|
},
|
|
|
|
bindServerStream<Input, Event>(
|
|
operationId: string,
|
|
isEvent: (value: unknown) => value is Event,
|
|
): BrowserRpcServerStreamPort<Input, Event> {
|
|
if (typeof isEvent !== "function") {
|
|
throw new TypeError("Browser RPC stream result guard is required.");
|
|
}
|
|
const bound = bind(operationId, "SERVER_STREAM");
|
|
return Object.freeze({
|
|
open: (input, context = {}) =>
|
|
executeServerStream(
|
|
dependencies,
|
|
bound,
|
|
input,
|
|
context,
|
|
isEvent,
|
|
generationFence,
|
|
clock,
|
|
),
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
async function executeUnary<Output>(
|
|
dependencies: BrowserRpcRuntimeDependencies,
|
|
bound: BoundOperation,
|
|
input: unknown,
|
|
context: Readonly<{ signal?: AbortSignal; idempotencyKey?: string }>,
|
|
isOutput: (value: unknown) => value is Output,
|
|
generationFence: BrowserRpcGenerationFence,
|
|
clock: ClockPort,
|
|
): Promise<Result<Output, AppFailure>> {
|
|
const { operation, profile, encoder, transport } = bound;
|
|
const generation = generationFence.capture();
|
|
const startedAt = clock.now();
|
|
const deadlineAt = startedAt + operation.totalDeadlineMs;
|
|
const linked = linkedAbortController(context.signal);
|
|
let attemptCount = 0;
|
|
|
|
const finish = (
|
|
result: Result<Output, AppFailure>,
|
|
outcome: BrowserRpcObservationOutcome,
|
|
): Result<Output, AppFailure> => {
|
|
linked.cleanup();
|
|
observe(dependencies, operation, outcome, attemptCount, result.ok ? 1 : 0);
|
|
return result;
|
|
};
|
|
|
|
const contextFailure = validateCallContext(operation, context, 0);
|
|
if (contextFailure) {
|
|
return finish(failureResult(contextFailure), "CONTRACT_REJECTED");
|
|
}
|
|
if (linked.controller.signal.aborted) {
|
|
return finish(
|
|
failureResult(callFailure(operation, 0, "REQUEST_ABORTED", "RPC_ABORTED")),
|
|
"ABORTED",
|
|
);
|
|
}
|
|
|
|
const prepared = prepareRequest(dependencies, operation, encoder, input, 0);
|
|
if (!prepared.ok) {
|
|
return finish(failureResult(prepared.error), "CONTRACT_REJECTED");
|
|
}
|
|
|
|
for (let attempt = 0; attempt < profile.maxAttempts; attempt += 1) {
|
|
attemptCount = attempt + 1;
|
|
const remainingMs = deadlineAt - clock.now();
|
|
if (remainingMs <= 0) {
|
|
linked.controller.abort("deadline");
|
|
return finish(
|
|
failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"REQUEST_TIMEOUT",
|
|
"RPC_TOTAL_DEADLINE_EXCEEDED",
|
|
),
|
|
),
|
|
"TIMEOUT",
|
|
);
|
|
}
|
|
const call = Object.freeze({
|
|
operation,
|
|
profile,
|
|
request: prepared.value,
|
|
encodedRequestBytes: prepared.encodedBytes,
|
|
attempt: attempt + 1,
|
|
timeoutMs: Math.max(1, Math.min(remainingMs, operation.totalDeadlineMs)),
|
|
signal: linked.controller.signal,
|
|
...(context.idempotencyKey
|
|
? { idempotencyKey: context.idempotencyKey }
|
|
: {}),
|
|
});
|
|
const timed = await raceWithin(
|
|
Promise.resolve().then(() => transport.invokeUnary!(call)),
|
|
remainingMs,
|
|
linked.controller.signal,
|
|
clock,
|
|
);
|
|
if (timed.kind === "TIMEOUT") {
|
|
linked.controller.abort("deadline");
|
|
return finish(
|
|
failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"REQUEST_TIMEOUT",
|
|
"RPC_TOTAL_DEADLINE_EXCEEDED",
|
|
),
|
|
),
|
|
"TIMEOUT",
|
|
);
|
|
}
|
|
if (timed.kind === "ABORTED" || linked.controller.signal.aborted) {
|
|
return finish(
|
|
failureResult(
|
|
callFailure(operation, attempt, "REQUEST_ABORTED", "RPC_ABORTED"),
|
|
),
|
|
"ABORTED",
|
|
);
|
|
}
|
|
if (timed.kind === "THREW") {
|
|
return finish(
|
|
failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"SERVER_FAILURE",
|
|
"RPC_TRANSPORT_EXECUTION_FAILED",
|
|
),
|
|
),
|
|
"FAILED",
|
|
);
|
|
}
|
|
|
|
const transportResult = validateUnaryTransportResult(timed.value);
|
|
if (!transportResult) {
|
|
return finish(
|
|
failureResult(protocolFailure(operation, attempt)),
|
|
"CONTRACT_REJECTED",
|
|
);
|
|
}
|
|
if (!transportResult.ok) {
|
|
if (
|
|
shouldRetry(
|
|
operation,
|
|
profile,
|
|
transportResult.failure,
|
|
attempt,
|
|
context.idempotencyKey,
|
|
)
|
|
) {
|
|
const delay = retryDelay(profile, transportResult.failure, attempt);
|
|
if (delay >= deadlineAt - clock.now()) {
|
|
linked.controller.abort("deadline");
|
|
return finish(
|
|
failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"REQUEST_TIMEOUT",
|
|
"RPC_RETRY_BUDGET_EXHAUSTED",
|
|
),
|
|
),
|
|
"TIMEOUT",
|
|
);
|
|
}
|
|
try {
|
|
await clock.sleep(delay, linked.controller.signal);
|
|
} catch {
|
|
return finish(
|
|
failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"REQUEST_ABORTED",
|
|
"RPC_ABORTED",
|
|
),
|
|
),
|
|
"ABORTED",
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
const mapped = mapTransportFailure(
|
|
operation,
|
|
attempt,
|
|
transportResult.failure,
|
|
);
|
|
return finish(
|
|
failureResult(mapped),
|
|
mapped.kind === "REQUEST_ABORTED"
|
|
? "ABORTED"
|
|
: mapped.kind === "REQUEST_TIMEOUT"
|
|
? "TIMEOUT"
|
|
: "FAILED",
|
|
);
|
|
}
|
|
|
|
const mapped = mapResponse(
|
|
dependencies,
|
|
operation,
|
|
attempt,
|
|
transportResult.message,
|
|
transportResult.encodedBytes,
|
|
generation,
|
|
generationFence,
|
|
isOutput,
|
|
deadlineAt,
|
|
clock,
|
|
);
|
|
return finish(
|
|
mapped,
|
|
mapped.ok
|
|
? "SUCCESS"
|
|
: mapped.error.kind === "REQUEST_TIMEOUT"
|
|
? "TIMEOUT"
|
|
: "CONTRACT_REJECTED",
|
|
);
|
|
}
|
|
|
|
return finish(
|
|
failureResult(
|
|
callFailure(
|
|
operation,
|
|
Math.max(0, attemptCount - 1),
|
|
"SERVER_FAILURE",
|
|
"RPC_RETRY_EXHAUSTED",
|
|
),
|
|
),
|
|
"FAILED",
|
|
);
|
|
}
|
|
|
|
async function* executeServerStream<Event>(
|
|
dependencies: BrowserRpcRuntimeDependencies,
|
|
bound: BoundOperation,
|
|
input: unknown,
|
|
context: Readonly<{ signal?: AbortSignal; idempotencyKey?: string }>,
|
|
isEvent: (value: unknown) => value is Event,
|
|
generationFence: BrowserRpcGenerationFence,
|
|
clock: ClockPort,
|
|
): AsyncIterable<Result<Event, AppFailure>> {
|
|
const { operation, profile, encoder, transport } = bound;
|
|
const generation = generationFence.capture();
|
|
const deadlineAt = clock.now() + operation.totalDeadlineMs;
|
|
const linked = linkedAbortController(context.signal);
|
|
let iterator: AsyncIterator<BrowserRpcStreamFrame> | null = null;
|
|
let observed = false;
|
|
let messageCount = 0;
|
|
|
|
const finish = (outcome: BrowserRpcObservationOutcome) => {
|
|
if (observed) return;
|
|
observed = true;
|
|
observe(dependencies, operation, outcome, 1, messageCount);
|
|
};
|
|
|
|
try {
|
|
const contextFailure = validateCallContext(operation, context, 0);
|
|
if (contextFailure) {
|
|
finish("CONTRACT_REJECTED");
|
|
yield failureResult(contextFailure);
|
|
return;
|
|
}
|
|
if (linked.controller.signal.aborted) {
|
|
finish("ABORTED");
|
|
yield failureResult(
|
|
callFailure(operation, 0, "REQUEST_ABORTED", "RPC_ABORTED"),
|
|
);
|
|
return;
|
|
}
|
|
const prepared = prepareRequest(
|
|
dependencies,
|
|
operation,
|
|
encoder,
|
|
input,
|
|
0,
|
|
);
|
|
if (!prepared.ok) {
|
|
finish("CONTRACT_REJECTED");
|
|
yield failureResult(prepared.error);
|
|
return;
|
|
}
|
|
|
|
const remainingMs = deadlineAt - clock.now();
|
|
if (remainingMs <= 0) {
|
|
finish("TIMEOUT");
|
|
yield failureResult(
|
|
callFailure(
|
|
operation,
|
|
0,
|
|
"REQUEST_TIMEOUT",
|
|
"RPC_TOTAL_DEADLINE_EXCEEDED",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
let stream: AsyncIterable<BrowserRpcStreamFrame>;
|
|
try {
|
|
stream = transport.openServerStream!(
|
|
Object.freeze({
|
|
operation,
|
|
profile,
|
|
request: prepared.value,
|
|
encodedRequestBytes: prepared.encodedBytes,
|
|
attempt: 1,
|
|
timeoutMs: Math.max(1, remainingMs),
|
|
signal: linked.controller.signal,
|
|
...(context.idempotencyKey
|
|
? { idempotencyKey: context.idempotencyKey }
|
|
: {}),
|
|
}),
|
|
);
|
|
iterator = stream[Symbol.asyncIterator]();
|
|
} catch {
|
|
finish("FAILED");
|
|
yield failureResult(
|
|
callFailure(
|
|
operation,
|
|
0,
|
|
"SERVER_FAILURE",
|
|
"RPC_TRANSPORT_EXECUTION_FAILED",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let totalBytes = 0;
|
|
let terminal:
|
|
| Readonly<{ ok: true }>
|
|
| Readonly<{ ok: false; failure: BrowserRpcTransportFailure }>
|
|
| null = null;
|
|
|
|
while (true) {
|
|
const totalRemaining = deadlineAt - clock.now();
|
|
if (totalRemaining <= 0) {
|
|
linked.controller.abort("deadline");
|
|
finish("TIMEOUT");
|
|
yield failureResult(
|
|
callFailure(
|
|
operation,
|
|
0,
|
|
"REQUEST_TIMEOUT",
|
|
"RPC_TOTAL_DEADLINE_EXCEEDED",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const waitMs = Math.min(
|
|
totalRemaining,
|
|
operation.idleDeadlineMs ?? totalRemaining,
|
|
);
|
|
const next = await raceWithin(
|
|
Promise.resolve().then(() => iterator!.next()),
|
|
waitMs,
|
|
linked.controller.signal,
|
|
clock,
|
|
);
|
|
if (next.kind === "TIMEOUT") {
|
|
linked.controller.abort("idle-or-deadline");
|
|
const totalExpired = clock.now() >= deadlineAt;
|
|
finish("TIMEOUT");
|
|
yield failureResult(
|
|
callFailure(
|
|
operation,
|
|
0,
|
|
"REQUEST_TIMEOUT",
|
|
totalExpired
|
|
? "RPC_TOTAL_DEADLINE_EXCEEDED"
|
|
: "RPC_STREAM_IDLE_TIMEOUT",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (next.kind === "ABORTED" || linked.controller.signal.aborted) {
|
|
finish("ABORTED");
|
|
yield failureResult(
|
|
callFailure(operation, 0, "REQUEST_ABORTED", "RPC_ABORTED"),
|
|
);
|
|
return;
|
|
}
|
|
if (next.kind === "THREW") {
|
|
finish("FAILED");
|
|
yield failureResult(
|
|
callFailure(
|
|
operation,
|
|
0,
|
|
"SERVER_FAILURE",
|
|
"RPC_STREAM_EXECUTION_FAILED",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (next.value.done) {
|
|
if (!terminal) {
|
|
finish("CONTRACT_REJECTED");
|
|
yield failureResult(protocolFailure(operation, 0));
|
|
return;
|
|
}
|
|
if (!terminal.ok) {
|
|
const mapped = mapTransportFailure(
|
|
operation,
|
|
0,
|
|
terminal.failure,
|
|
);
|
|
finish(
|
|
mapped.kind === "REQUEST_ABORTED"
|
|
? "ABORTED"
|
|
: mapped.kind === "REQUEST_TIMEOUT"
|
|
? "TIMEOUT"
|
|
: "FAILED",
|
|
);
|
|
yield failureResult(mapped);
|
|
return;
|
|
}
|
|
finish("SUCCESS");
|
|
return;
|
|
}
|
|
|
|
const frame = validateStreamFrame(next.value.value);
|
|
if (!frame || terminal) {
|
|
finish("CONTRACT_REJECTED");
|
|
yield failureResult(protocolFailure(operation, 0));
|
|
return;
|
|
}
|
|
if (frame.kind === "TERMINAL") {
|
|
terminal = frame.ok
|
|
? Object.freeze({ ok: true })
|
|
: Object.freeze({ ok: false, failure: frame.failure });
|
|
continue;
|
|
}
|
|
|
|
messageCount += 1;
|
|
totalBytes += frame.encodedBytes;
|
|
if (
|
|
messageCount > operation.maxResponseMessages ||
|
|
frame.encodedBytes > operation.maxResponseMessageBytes ||
|
|
totalBytes > operation.maxTotalResponseBytes
|
|
) {
|
|
linked.controller.abort("message-limit");
|
|
finish("CONTRACT_REJECTED");
|
|
yield failureResult(
|
|
callFailure(
|
|
operation,
|
|
0,
|
|
"RESPONSE_BODY_LIMIT",
|
|
"RPC_STREAM_MESSAGE_LIMIT",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const mapped = mapResponse(
|
|
dependencies,
|
|
operation,
|
|
0,
|
|
frame.message,
|
|
frame.encodedBytes,
|
|
generation,
|
|
generationFence,
|
|
isEvent,
|
|
deadlineAt,
|
|
clock,
|
|
);
|
|
if (!mapped.ok) {
|
|
linked.controller.abort("mapping-failure");
|
|
finish(
|
|
mapped.error.kind === "REQUEST_TIMEOUT"
|
|
? "TIMEOUT"
|
|
: "CONTRACT_REJECTED",
|
|
);
|
|
yield mapped;
|
|
return;
|
|
}
|
|
yield mapped;
|
|
}
|
|
} finally {
|
|
linked.controller.abort("stream-closed");
|
|
linked.cleanup();
|
|
if (iterator?.return) {
|
|
try {
|
|
await iterator.return();
|
|
} catch {
|
|
// Cleanup cannot replace the already selected stream outcome.
|
|
}
|
|
}
|
|
finish("ABORTED");
|
|
}
|
|
}
|
|
|
|
function validateRuntimeDependencies(
|
|
dependencies: BrowserRpcRuntimeDependencies,
|
|
): void {
|
|
const runtimeBindings: Record<
|
|
string,
|
|
Pick<
|
|
BrowserRpcTransport,
|
|
"runtimeProfileId" | "providerId" | "protocol" | "rpcKind"
|
|
>
|
|
> = Object.create(null);
|
|
for (const [profileId, transport] of Object.entries(
|
|
dependencies.transports,
|
|
)) {
|
|
if (
|
|
profileId !== transport.runtimeProfileId ||
|
|
Object.hasOwn(runtimeBindings, profileId)
|
|
) {
|
|
throw new TypeError(
|
|
`Browser RPC transport registry is invalid: ${profileId}`,
|
|
);
|
|
}
|
|
runtimeBindings[profileId] = Object.freeze({
|
|
runtimeProfileId: transport.runtimeProfileId,
|
|
providerId: transport.providerId,
|
|
protocol: transport.protocol,
|
|
rpcKind: transport.rpcKind,
|
|
});
|
|
}
|
|
validateBrowserRpcContractBindings({
|
|
operations: dependencies.operations,
|
|
profiles: dependencies.profiles,
|
|
schemaCodecs: dependencies.schemaCodecs,
|
|
mappers: dependencies.mappers,
|
|
requestEncoders: dependencies.requestEncoders,
|
|
runtimeBindings: Object.freeze(runtimeBindings),
|
|
});
|
|
for (const operation of Object.values(dependencies.operations)) {
|
|
if (!dependencies.transports[operation.runtimeProfileId]) {
|
|
throw new TypeError(
|
|
`Browser RPC transport is missing: ${operation.operationId}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function prepareRequest(
|
|
dependencies: BrowserRpcRuntimeDependencies,
|
|
operation: BrowserRpcOperationV3,
|
|
encoder: BrowserRpcRequestEncoder,
|
|
input: unknown,
|
|
attempt: number,
|
|
): PreparedRequest {
|
|
const schema = dependencies.schemaCodecs[operation.requestSchemaId];
|
|
let validated;
|
|
try {
|
|
validated = schema?.parse(input);
|
|
} catch {
|
|
validated = undefined;
|
|
}
|
|
if (!validated?.success) {
|
|
return Object.freeze({
|
|
ok: false,
|
|
error: callFailure(
|
|
operation,
|
|
attempt,
|
|
"VALIDATION_REJECTED",
|
|
"RPC_REQUEST_SCHEMA_INVALID",
|
|
),
|
|
});
|
|
}
|
|
try {
|
|
const encoded = encoder.encode(validated.data);
|
|
if (
|
|
!encoded.ok ||
|
|
!validEncodedByteCount(
|
|
encoded.encodedBytes,
|
|
operation.maxRequestMessageBytes,
|
|
)
|
|
) {
|
|
return Object.freeze({
|
|
ok: false,
|
|
error: callFailure(
|
|
operation,
|
|
attempt,
|
|
"MAPPING_CONTRACT_VIOLATION",
|
|
encoded.ok ? "RPC_REQUEST_MESSAGE_LIMIT" : safeCode(encoded.code),
|
|
),
|
|
});
|
|
}
|
|
return Object.freeze({
|
|
ok: true,
|
|
value: encoded.value,
|
|
encodedBytes: encoded.encodedBytes,
|
|
});
|
|
} catch {
|
|
return Object.freeze({
|
|
ok: false,
|
|
error: callFailure(
|
|
operation,
|
|
attempt,
|
|
"MAPPING_CONTRACT_VIOLATION",
|
|
"RPC_REQUEST_ENCODING_FAILED",
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
function mapResponse<Output>(
|
|
dependencies: BrowserRpcRuntimeDependencies,
|
|
operation: BrowserRpcOperationV3,
|
|
attempt: number,
|
|
message: unknown,
|
|
encodedBytes: number,
|
|
generation: unknown,
|
|
generationFence: BrowserRpcGenerationFence,
|
|
isOutput: (value: unknown) => value is Output,
|
|
deadlineAt: number,
|
|
clock: ClockPort,
|
|
): Result<Output, AppFailure> {
|
|
if (
|
|
!validEncodedByteCount(
|
|
encodedBytes,
|
|
operation.maxResponseMessageBytes,
|
|
)
|
|
) {
|
|
return failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"RESPONSE_BODY_LIMIT",
|
|
"RPC_RESPONSE_MESSAGE_LIMIT",
|
|
),
|
|
);
|
|
}
|
|
if (clock.now() >= deadlineAt) {
|
|
return failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"REQUEST_TIMEOUT",
|
|
"RPC_TOTAL_DEADLINE_EXCEEDED",
|
|
),
|
|
);
|
|
}
|
|
|
|
const schema = dependencies.schemaCodecs[operation.responseSchemaId];
|
|
let validated;
|
|
try {
|
|
validated = schema?.parse(message);
|
|
} catch {
|
|
validated = undefined;
|
|
}
|
|
if (!validated?.success) {
|
|
return failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"SCHEMA_MISMATCH",
|
|
"RPC_RESPONSE_SCHEMA_INVALID",
|
|
),
|
|
);
|
|
}
|
|
const mapper = dependencies.mappers[operation.mapperId];
|
|
let mapped;
|
|
try {
|
|
mapped = mapper?.map(validated.data);
|
|
} catch {
|
|
mapped = undefined;
|
|
}
|
|
if (!mapped?.ok) {
|
|
return failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"MAPPING_CONTRACT_VIOLATION",
|
|
mapped?.code ?? "RPC_RESPONSE_MAPPING_FAILED",
|
|
),
|
|
);
|
|
}
|
|
const output = mapped.value;
|
|
const outputMatches = safelyMatches(isOutput, output);
|
|
if (!outputMatches) {
|
|
return failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"MAPPING_CONTRACT_VIOLATION",
|
|
"RPC_BOUND_RESULT_TYPE_MISMATCH",
|
|
),
|
|
);
|
|
}
|
|
if (!generationFence.isCurrent(generation)) {
|
|
return failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"SCOPE_GENERATION_CHANGED",
|
|
"RPC_SCOPE_GENERATION_CHANGED",
|
|
),
|
|
);
|
|
}
|
|
if (clock.now() >= deadlineAt) {
|
|
return failureResult(
|
|
callFailure(
|
|
operation,
|
|
attempt,
|
|
"REQUEST_TIMEOUT",
|
|
"RPC_TOTAL_DEADLINE_EXCEEDED",
|
|
),
|
|
);
|
|
}
|
|
return Object.freeze({ ok: true, value: output });
|
|
}
|
|
|
|
function validateCallContext(
|
|
operation: BrowserRpcOperationV3,
|
|
context: Readonly<{ idempotencyKey?: string }>,
|
|
attempt: number,
|
|
): AppFailure | null {
|
|
const key = context.idempotencyKey;
|
|
if (
|
|
(operation.idempotencyKeyPolicy === "REQUIRED" &&
|
|
!validIdempotencyKey(key)) ||
|
|
(operation.idempotencyKeyPolicy === "NONE" && key !== undefined)
|
|
) {
|
|
return callFailure(
|
|
operation,
|
|
attempt,
|
|
"VALIDATION_REJECTED",
|
|
"RPC_IDEMPOTENCY_KEY_INVALID",
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function validIdempotencyKey(value: unknown): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
value.length >= 8 &&
|
|
value.length <= 200 &&
|
|
![...value].some((character) => {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
return codePoint <= 31 || codePoint === 127 || /\s/u.test(character);
|
|
})
|
|
);
|
|
}
|
|
|
|
function safelyMatches<Output>(
|
|
guard: (value: unknown) => value is Output,
|
|
value: unknown,
|
|
): value is Output {
|
|
try {
|
|
return guard(value);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function validateUnaryTransportResult(
|
|
value: unknown,
|
|
): BrowserRpcUnaryTransportResult | null {
|
|
if (!value || typeof value !== "object" || !("ok" in value)) return null;
|
|
const candidate = value as BrowserRpcUnaryTransportResult;
|
|
if (candidate.ok) {
|
|
return validEncodedByteCount(candidate.encodedBytes, Number.MAX_SAFE_INTEGER)
|
|
? candidate
|
|
: null;
|
|
}
|
|
return validTransportFailure(candidate.failure) ? candidate : null;
|
|
}
|
|
|
|
function validateStreamFrame(value: unknown): BrowserRpcStreamFrame | null {
|
|
if (!value || typeof value !== "object" || !("kind" in value)) return null;
|
|
const frame = value as BrowserRpcStreamFrame;
|
|
if (frame.kind === "MESSAGE") {
|
|
return validEncodedByteCount(frame.encodedBytes, Number.MAX_SAFE_INTEGER)
|
|
? frame
|
|
: null;
|
|
}
|
|
if (frame.kind !== "TERMINAL" || typeof frame.ok !== "boolean") return null;
|
|
return frame.ok || validTransportFailure(frame.failure) ? frame : null;
|
|
}
|
|
|
|
function validTransportFailure(
|
|
failure: unknown,
|
|
): failure is BrowserRpcTransportFailure {
|
|
if (!failure || typeof failure !== "object" || !("code" in failure)) {
|
|
return false;
|
|
}
|
|
const candidate = failure as BrowserRpcTransportFailure;
|
|
return (
|
|
TRANSPORT_FAILURE_CODES.has(candidate.code) &&
|
|
(candidate.retryAfterMs === undefined ||
|
|
(Number.isSafeInteger(candidate.retryAfterMs) &&
|
|
candidate.retryAfterMs >= 0 &&
|
|
candidate.retryAfterMs <= BROWSER_RPC_HARD_LIMITS.maxRetryAfterMs))
|
|
);
|
|
}
|
|
|
|
const TRANSPORT_FAILURE_CODES =
|
|
new Set<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",
|
|
]);
|
|
|
|
function shouldRetry(
|
|
operation: BrowserRpcOperationV3,
|
|
profile: BrowserRpcProviderProfile,
|
|
failure: BrowserRpcTransportFailure,
|
|
attempt: number,
|
|
idempotencyKey: string | undefined,
|
|
): boolean {
|
|
return (
|
|
profile.retryOwner === "FRONTEND_ADAPTER" &&
|
|
attempt + 1 < profile.maxAttempts &&
|
|
profile.retryableFailures.includes(failure.code) &&
|
|
(["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy) ||
|
|
(operation.replayPolicy === "KEYED_COMMAND" &&
|
|
validIdempotencyKey(idempotencyKey)))
|
|
);
|
|
}
|
|
|
|
function retryDelay(
|
|
profile: BrowserRpcProviderProfile,
|
|
failure: BrowserRpcTransportFailure,
|
|
attempt: number,
|
|
): number {
|
|
const backoff = profile.backoffMs[attempt] ?? 0;
|
|
const retryAfter = Math.min(
|
|
failure.retryAfterMs ?? 0,
|
|
profile.maxRetryAfterMs,
|
|
);
|
|
return Math.max(backoff, retryAfter);
|
|
}
|
|
|
|
function mapTransportFailure(
|
|
operation: BrowserRpcOperationV3,
|
|
attempt: number,
|
|
failure: BrowserRpcTransportFailure,
|
|
): AppFailure {
|
|
const kind: FailureKind =
|
|
failure.code === "NETWORK_UNREACHABLE"
|
|
? "NETWORK_UNREACHABLE"
|
|
: failure.code === "CANCELED"
|
|
? "REQUEST_ABORTED"
|
|
: failure.code === "DEADLINE_EXCEEDED"
|
|
? "REQUEST_TIMEOUT"
|
|
: failure.code === "UNAUTHENTICATED"
|
|
? "AUTH_REQUIRED"
|
|
: failure.code === "PERMISSION_DENIED"
|
|
? "FORBIDDEN"
|
|
: failure.code === "NOT_FOUND"
|
|
? "NOT_FOUND"
|
|
: ["ALREADY_EXISTS", "ABORTED"].includes(failure.code)
|
|
? "CONFLICT"
|
|
: ["FAILED_PRECONDITION", "INVALID_ARGUMENT"].includes(
|
|
failure.code,
|
|
)
|
|
? "VALIDATION_REJECTED"
|
|
: failure.code === "RESOURCE_EXHAUSTED"
|
|
? "RATE_LIMITED"
|
|
: failure.code === "MESSAGE_LIMIT"
|
|
? "RESPONSE_BODY_LIMIT"
|
|
: ["PROTOCOL_MISMATCH", "UNIMPLEMENTED"].includes(
|
|
failure.code,
|
|
)
|
|
? "API_CONTRACT_MISMATCH"
|
|
: "SERVER_FAILURE";
|
|
return createFailure(kind, operation.operationId, attempt, {
|
|
code: `RPC_${failure.code}`,
|
|
...(failure.retryAfterMs !== undefined
|
|
? { retryAfterMs: failure.retryAfterMs }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
function protocolFailure(
|
|
operation: BrowserRpcOperationV3,
|
|
attempt: number,
|
|
): AppFailure {
|
|
return callFailure(
|
|
operation,
|
|
attempt,
|
|
"API_CONTRACT_MISMATCH",
|
|
"RPC_PROTOCOL_MISMATCH",
|
|
);
|
|
}
|
|
|
|
function callFailure(
|
|
operation: BrowserRpcOperationV3,
|
|
attempt: number,
|
|
kind: FailureKind,
|
|
code: string,
|
|
): AppFailure {
|
|
return createFailure(kind, operation.operationId, attempt, {
|
|
code: safeCode(code),
|
|
});
|
|
}
|
|
|
|
function safeCode(value: string): string {
|
|
return /^[A-Z][A-Z0-9_]{2,79}$/.test(value)
|
|
? value
|
|
: "RPC_ADAPTER_REJECTED";
|
|
}
|
|
|
|
function failureResult(
|
|
error: AppFailure,
|
|
): Readonly<{ ok: false; error: AppFailure }> {
|
|
return Object.freeze({ ok: false, error });
|
|
}
|
|
|
|
function validEncodedByteCount(value: number, maximum: number): boolean {
|
|
return (
|
|
Number.isSafeInteger(value) &&
|
|
value >= 0 &&
|
|
value <= maximum
|
|
);
|
|
}
|
|
|
|
function linkedAbortController(external?: AbortSignal): Readonly<{
|
|
controller: AbortController;
|
|
cleanup(): void;
|
|
}> {
|
|
const controller = new AbortController();
|
|
const onAbort = () => controller.abort(external?.reason);
|
|
external?.addEventListener("abort", onAbort, { once: true });
|
|
if (external?.aborted) onAbort();
|
|
return Object.freeze({
|
|
controller,
|
|
cleanup() {
|
|
external?.removeEventListener("abort", onAbort);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function raceWithin<Value>(
|
|
work: Promise<Value>,
|
|
milliseconds: number,
|
|
signal: AbortSignal,
|
|
clock: ClockPort,
|
|
): Promise<TimedResult<Value>> {
|
|
if (signal.aborted) return Object.freeze({ kind: "ABORTED" });
|
|
const timerController = new AbortController();
|
|
const onAbort = () => timerController.abort(signal.reason);
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
const workResult = work.then<TimedResult<Value>, TimedResult<Value>>(
|
|
(value) => Object.freeze({ kind: "VALUE", value }),
|
|
() => Object.freeze({ kind: "THREW" }),
|
|
);
|
|
const timerResult = clock.sleep(milliseconds, timerController.signal).then<
|
|
TimedResult<Value>,
|
|
TimedResult<Value>
|
|
>(
|
|
() => Object.freeze({ kind: "TIMEOUT" }),
|
|
() => Object.freeze({ kind: "ABORTED" }),
|
|
);
|
|
const selected = await Promise.race([workResult, timerResult]);
|
|
timerController.abort("race-complete");
|
|
signal.removeEventListener("abort", onAbort);
|
|
return signal.aborted && selected.kind === "VALUE"
|
|
? Object.freeze({ kind: "ABORTED" })
|
|
: selected;
|
|
}
|
|
|
|
function observe(
|
|
dependencies: BrowserRpcRuntimeDependencies,
|
|
operation: BrowserRpcOperationV3,
|
|
outcome: BrowserRpcObservationOutcome,
|
|
attemptCount: number,
|
|
messageCount: number,
|
|
): void {
|
|
try {
|
|
dependencies.observations?.observe(
|
|
Object.freeze({
|
|
operationId: operation.operationId,
|
|
protocol: operation.protocol,
|
|
runtimeProfileId: operation.runtimeProfileId,
|
|
rpcKind: operation.rpcKind,
|
|
outcome,
|
|
attemptCount: Math.max(1, attemptCount),
|
|
messageCount: Math.max(0, messageCount),
|
|
}),
|
|
);
|
|
} catch {
|
|
// Observation cannot change the selected application result.
|
|
}
|
|
}
|