Files
clean-architecture-frontend…/src/adapters/browser-rpc/browser-rpc-runtime.ts
T

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