chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
export type ApiOperation = Readonly<{
|
||||
method: string;
|
||||
path: string;
|
||||
operationId: string;
|
||||
auth: "none" | "external-session";
|
||||
timeoutMs: number | null;
|
||||
idempotency: "safe" | "keyed" | "none";
|
||||
retry: "runtime" | "never";
|
||||
requestSource: "search" | "body" | "none";
|
||||
requestSchema: string;
|
||||
responseSchema: string;
|
||||
owner: string;
|
||||
contractVersion?: 2;
|
||||
protocol?: "REST";
|
||||
semantics?: "QUERY" | "COMMAND";
|
||||
replayPolicy?: "SAFE" | "IDEMPOTENT" | "KEYED_COMMAND" | "NON_REPLAYABLE";
|
||||
idempotencyKeyPolicy?: "NONE" | "REQUIRED";
|
||||
mapperId?: string;
|
||||
successStatuses?: readonly number[];
|
||||
responseMediaTypes?: readonly string[];
|
||||
maxResponseBytes?: number;
|
||||
providerId?: string;
|
||||
authProfileId?: string;
|
||||
csrfProfileId?: string;
|
||||
pathSchema?: string;
|
||||
pathParameterNames?: readonly string[];
|
||||
maxEncodedSearchBytes?: number;
|
||||
}>;
|
||||
|
||||
export type RestOperationV2 = ApiOperation &
|
||||
Readonly<{
|
||||
contractVersion: 2;
|
||||
protocol: "REST";
|
||||
semantics: "QUERY" | "COMMAND";
|
||||
replayPolicy:
|
||||
| "SAFE"
|
||||
| "IDEMPOTENT"
|
||||
| "KEYED_COMMAND"
|
||||
| "NON_REPLAYABLE";
|
||||
idempotencyKeyPolicy: "NONE" | "REQUIRED";
|
||||
mapperId: string;
|
||||
successStatuses: readonly number[];
|
||||
responseMediaTypes: readonly string[];
|
||||
maxResponseBytes: number;
|
||||
providerId: string;
|
||||
authProfileId: string;
|
||||
csrfProfileId: string;
|
||||
pathSchema: string;
|
||||
pathParameterNames: readonly string[];
|
||||
maxEncodedSearchBytes: number;
|
||||
}>;
|
||||
|
||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
const OPERATION_ID = /^[A-Z][A-Z0-9_]{2,79}$/;
|
||||
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/;
|
||||
|
||||
export function defineRestOperation(
|
||||
operation: RestOperationV2,
|
||||
): RestOperationV2 {
|
||||
validateRestOperation(operation);
|
||||
return Object.freeze({
|
||||
...operation,
|
||||
successStatuses: Object.freeze([...operation.successStatuses]),
|
||||
responseMediaTypes: Object.freeze([...operation.responseMediaTypes]),
|
||||
pathParameterNames: Object.freeze([...operation.pathParameterNames]),
|
||||
});
|
||||
}
|
||||
|
||||
export function composeApiOperations(
|
||||
contributions: readonly Readonly<Record<string, ApiOperation>>[],
|
||||
): Readonly<Record<string, ApiOperation>> {
|
||||
const result: Record<string, ApiOperation> = Object.create(null);
|
||||
for (const contribution of contributions) {
|
||||
for (const [registryId, operation] of Object.entries(contribution)) {
|
||||
if (registryId !== operation.operationId) {
|
||||
throw new TypeError("API operation registry key does not match operationId.");
|
||||
}
|
||||
if (Object.hasOwn(result, registryId)) {
|
||||
throw new TypeError(`Duplicate API operation: ${registryId}`);
|
||||
}
|
||||
if (operation.contractVersion === 2) {
|
||||
validateRestOperation(operation as RestOperationV2);
|
||||
}
|
||||
result[registryId] = operation;
|
||||
}
|
||||
}
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
export function validateApiRuntimeBindings(
|
||||
operations: Readonly<Record<string, ApiOperation>>,
|
||||
schemaMetadata: Readonly<Record<string, Readonly<{ schemaId: string }>>>,
|
||||
schemaCodecs: Readonly<Record<string, Readonly<{ schemaId: string }>>>,
|
||||
mappers: Readonly<
|
||||
Record<
|
||||
string,
|
||||
Readonly<{ mapperId: string; inputSchemaId: string; maxOutputItems: number }>
|
||||
>
|
||||
>,
|
||||
): true {
|
||||
for (const operation of Object.values(operations)) {
|
||||
if (operation.contractVersion !== 2) continue;
|
||||
for (const schemaId of [
|
||||
operation.pathSchema,
|
||||
operation.requestSchema,
|
||||
operation.responseSchema,
|
||||
]) {
|
||||
if (
|
||||
!schemaId ||
|
||||
schemaMetadata[schemaId]?.schemaId !== schemaId ||
|
||||
schemaCodecs[schemaId]?.schemaId !== schemaId
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Unresolved API schema binding: ${operation.operationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const mapper = mappers[operation.mapperId ?? ""];
|
||||
if (
|
||||
!mapper ||
|
||||
mapper.mapperId !== operation.mapperId ||
|
||||
mapper.inputSchemaId !== operation.responseSchema ||
|
||||
mapper.maxOutputItems < 1
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Unresolved API mapper binding: ${operation.operationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateRestOperation(operation: RestOperationV2): void {
|
||||
if (
|
||||
operation.protocol !== "REST" ||
|
||||
!OPERATION_ID.test(operation.operationId) ||
|
||||
!operation.owner ||
|
||||
!operation.mapperId ||
|
||||
!operation.providerId ||
|
||||
!operation.authProfileId ||
|
||||
!operation.csrfProfileId ||
|
||||
!operation.pathSchema ||
|
||||
!operation.path.startsWith("/") ||
|
||||
operation.path.startsWith("//") ||
|
||||
operation.path.includes("?") ||
|
||||
operation.path.includes("#") ||
|
||||
!Number.isSafeInteger(operation.maxResponseBytes) ||
|
||||
operation.maxResponseBytes < 1 ||
|
||||
operation.maxResponseBytes > MAX_RESPONSE_BYTES ||
|
||||
!Number.isSafeInteger(operation.maxEncodedSearchBytes) ||
|
||||
operation.maxEncodedSearchBytes < 0 ||
|
||||
operation.maxEncodedSearchBytes > 32_768 ||
|
||||
operation.successStatuses.length === 0 ||
|
||||
operation.successStatuses.some(
|
||||
(status) => !Number.isInteger(status) || status < 200 || status > 299,
|
||||
) ||
|
||||
new Set(operation.successStatuses).size !== operation.successStatuses.length ||
|
||||
operation.responseMediaTypes.length === 0 ||
|
||||
operation.responseMediaTypes.some(
|
||||
(value) => !MEDIA_TYPE.test(value) || value !== value.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new TypeError(`Invalid REST operation contract: ${operation.operationId}`);
|
||||
}
|
||||
const placeholders = [
|
||||
...operation.path.matchAll(
|
||||
/:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g,
|
||||
),
|
||||
]
|
||||
.map((match) => match[1] ?? match[2] ?? "")
|
||||
.sort();
|
||||
const codecKeys = [...operation.pathParameterNames].sort();
|
||||
if (
|
||||
new Set(codecKeys).size !== codecKeys.length ||
|
||||
placeholders.length !== codecKeys.length ||
|
||||
placeholders.some((name, index) => name !== codecKeys[index])
|
||||
) {
|
||||
throw new TypeError(
|
||||
`REST path codec does not match its template: ${operation.operationId}`,
|
||||
);
|
||||
}
|
||||
const isQuery = operation.semantics === "QUERY";
|
||||
if (
|
||||
(isQuery && !["GET", "HEAD"].includes(operation.method)) ||
|
||||
(isQuery && !["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy)) ||
|
||||
(operation.replayPolicy === "KEYED_COMMAND" &&
|
||||
(operation.idempotencyKeyPolicy !== "REQUIRED" ||
|
||||
operation.idempotency !== "keyed")) ||
|
||||
(operation.replayPolicy === "NON_REPLAYABLE" &&
|
||||
(operation.retry !== "never" || operation.idempotency !== "none"))
|
||||
) {
|
||||
throw new TypeError(`Incoherent REST replay contract: ${operation.operationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const API_OPERATIONS: Readonly<Record<string, ApiOperation>> =
|
||||
Object.freeze({});
|
||||
|
||||
export function getApiOperation(
|
||||
operationId: string,
|
||||
operations: Readonly<Record<string, ApiOperation>> = API_OPERATIONS,
|
||||
): ApiOperation {
|
||||
const selected = operations[operationId];
|
||||
if (!selected) {
|
||||
throw new Error(`Unregistered API operation: ${operationId}`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export type MappingFailureCode =
|
||||
| "MAPPING_INVARIANT_REJECTED"
|
||||
| "UNSUPPORTED_WIRE_VALUE"
|
||||
| "OUTPUT_LIMIT_EXCEEDED";
|
||||
|
||||
export type MappingResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; code: MappingFailureCode }>;
|
||||
|
||||
export type BoundaryMapper<Input, Output> = Readonly<{
|
||||
mapperId: string;
|
||||
mapperVersion: number;
|
||||
inputSchemaId: string;
|
||||
outputContractId: string;
|
||||
owner: string;
|
||||
maxOutputItems: number;
|
||||
map(input: Input): MappingResult<Output>;
|
||||
}>;
|
||||
|
||||
export type InstalledBoundaryMapper = BoundaryMapper<unknown, unknown>;
|
||||
|
||||
export function composeBoundaryMapperRegistry(
|
||||
contributions: readonly Readonly<Record<string, InstalledBoundaryMapper>>[],
|
||||
): Readonly<Record<string, InstalledBoundaryMapper>> {
|
||||
const result: Record<string, InstalledBoundaryMapper> = Object.create(null);
|
||||
for (const contribution of contributions) {
|
||||
for (const [registryId, mapper] of Object.entries(contribution)) {
|
||||
if (
|
||||
registryId !== mapper.mapperId ||
|
||||
!mapper.owner ||
|
||||
!mapper.inputSchemaId ||
|
||||
!mapper.outputContractId ||
|
||||
!Number.isSafeInteger(mapper.mapperVersion) ||
|
||||
mapper.mapperVersion < 1 ||
|
||||
!Number.isSafeInteger(mapper.maxOutputItems) ||
|
||||
mapper.maxOutputItems < 1 ||
|
||||
Object.hasOwn(result, registryId)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Invalid or duplicate boundary mapper: ${registryId}`,
|
||||
);
|
||||
}
|
||||
result[registryId] = mapper;
|
||||
}
|
||||
}
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
export function mapWithBoundaryRegistry(
|
||||
mapperId: string,
|
||||
input: unknown,
|
||||
registry: Readonly<Record<string, InstalledBoundaryMapper>>,
|
||||
): MappingResult<unknown> {
|
||||
const mapper = registry[mapperId];
|
||||
if (!mapper) return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
try {
|
||||
return mapper.map(input);
|
||||
} catch {
|
||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
}
|
||||
}
|
||||
|
||||
export function mappingSuccess<Value>(value: Value): MappingResult<Value> {
|
||||
return Object.freeze({ ok: true, value });
|
||||
}
|
||||
|
||||
export function mappingFailure(code: MappingFailureCode): MappingResult<never> {
|
||||
return Object.freeze({ ok: false, code });
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Cross-context cache invalidation is a best-effort hint protocol. The wire
|
||||
* event intentionally carries neither cached data nor a concrete query key.
|
||||
* Receivers resolve the allowlisted topic through their local policy.
|
||||
*/
|
||||
export const CACHE_INVALIDATION_PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
export const CACHE_INVALIDATION_WIRE_LIMITS = Object.freeze({
|
||||
maxWireBytes: 2_048,
|
||||
maxOpaqueIdentifierLength: 128,
|
||||
maxTopicLength: 64,
|
||||
maxEventTtlMs: 5 * 60 * 1_000,
|
||||
maxFutureClockSkewMs: 30_000,
|
||||
});
|
||||
|
||||
export type CacheInvalidationTopicDefinition = Readonly<{
|
||||
topic: string;
|
||||
topicVersion: number;
|
||||
}>;
|
||||
|
||||
export type CacheInvalidationWireEvent = Readonly<{
|
||||
protocolVersion: typeof CACHE_INVALIDATION_PROTOCOL_VERSION;
|
||||
eventId: string;
|
||||
sourceId: string;
|
||||
sourceEpoch: string;
|
||||
sequence: number;
|
||||
cacheEpoch: string;
|
||||
topic: string;
|
||||
topicVersion: number;
|
||||
emittedAt: number;
|
||||
expiresAt: number;
|
||||
}>;
|
||||
|
||||
export type CacheInvalidationParseFailureReason =
|
||||
| "CACHE_EPOCH_MISMATCH"
|
||||
| "EXPIRED"
|
||||
| "INVALID_ENVELOPE"
|
||||
| "MALFORMED_JSON"
|
||||
| "OVERSIZED"
|
||||
| "PROTOCOL_MISMATCH"
|
||||
| "TOPIC_REJECTED";
|
||||
|
||||
export type CacheInvalidationParseResult =
|
||||
| Readonly<{ ok: true; value: CacheInvalidationWireEvent }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
reason: CacheInvalidationParseFailureReason;
|
||||
}>;
|
||||
|
||||
export type CacheInvalidationParsePolicy = Readonly<{
|
||||
cacheEpoch: string;
|
||||
topicVersions: Readonly<Record<string, number>>;
|
||||
nowEpochMilliseconds: number;
|
||||
}>;
|
||||
|
||||
const WIRE_KEYS = Object.freeze([
|
||||
"cacheEpoch",
|
||||
"emittedAt",
|
||||
"eventId",
|
||||
"expiresAt",
|
||||
"protocolVersion",
|
||||
"sequence",
|
||||
"sourceEpoch",
|
||||
"sourceId",
|
||||
"topic",
|
||||
"topicVersion",
|
||||
] as const);
|
||||
|
||||
const OPAQUE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u;
|
||||
const TOPIC = /^[a-z][a-z0-9.-]*$/u;
|
||||
|
||||
export function isCacheInvalidationOpaqueIdentifier(
|
||||
value: unknown,
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length >= 1 &&
|
||||
value.length <=
|
||||
CACHE_INVALIDATION_WIRE_LIMITS.maxOpaqueIdentifierLength &&
|
||||
OPAQUE_IDENTIFIER.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isCacheInvalidationTopic(
|
||||
value: unknown,
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length >= 1 &&
|
||||
value.length <= CACHE_INVALIDATION_WIRE_LIMITS.maxTopicLength &&
|
||||
TOPIC.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function cacheInvalidationWireByteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
export function decodeCacheInvalidationWireEvent(
|
||||
raw: string,
|
||||
policy: CacheInvalidationParsePolicy,
|
||||
): CacheInvalidationParseResult {
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
raw.length > CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes ||
|
||||
cacheInvalidationWireByteLength(raw) >
|
||||
CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes
|
||||
) {
|
||||
return failure("OVERSIZED");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure("MALFORMED_JSON");
|
||||
}
|
||||
return parseCacheInvalidationWireEvent(parsed, policy);
|
||||
}
|
||||
|
||||
export function parseCacheInvalidationWireEvent(
|
||||
input: unknown,
|
||||
policy: CacheInvalidationParsePolicy,
|
||||
): CacheInvalidationParseResult {
|
||||
try {
|
||||
return parseCacheInvalidationWireEventUnsafe(input, policy);
|
||||
} catch {
|
||||
return failure("INVALID_ENVELOPE");
|
||||
}
|
||||
}
|
||||
|
||||
function parseCacheInvalidationWireEventUnsafe(
|
||||
input: unknown,
|
||||
policy: CacheInvalidationParsePolicy,
|
||||
): CacheInvalidationParseResult {
|
||||
if (!isExactWireRecord(input)) {
|
||||
return failure("INVALID_ENVELOPE");
|
||||
}
|
||||
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(input);
|
||||
} catch {
|
||||
return failure("INVALID_ENVELOPE");
|
||||
}
|
||||
if (
|
||||
cacheInvalidationWireByteLength(serialized) >
|
||||
CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes
|
||||
) {
|
||||
return failure("OVERSIZED");
|
||||
}
|
||||
|
||||
if (input.protocolVersion !== CACHE_INVALIDATION_PROTOCOL_VERSION) {
|
||||
return failure("PROTOCOL_MISMATCH");
|
||||
}
|
||||
if (
|
||||
!isCacheInvalidationOpaqueIdentifier(input.eventId) ||
|
||||
!isCacheInvalidationOpaqueIdentifier(input.sourceId) ||
|
||||
!isCacheInvalidationOpaqueIdentifier(input.sourceEpoch) ||
|
||||
!isCacheInvalidationOpaqueIdentifier(input.cacheEpoch) ||
|
||||
!isCacheInvalidationTopic(input.topic) ||
|
||||
!isPositiveSafeInteger(input.sequence) ||
|
||||
!isPositiveSafeInteger(input.topicVersion) ||
|
||||
!isEpochMilliseconds(input.emittedAt) ||
|
||||
!isEpochMilliseconds(input.expiresAt) ||
|
||||
input.expiresAt <= input.emittedAt ||
|
||||
input.expiresAt - input.emittedAt >
|
||||
CACHE_INVALIDATION_WIRE_LIMITS.maxEventTtlMs ||
|
||||
!isEpochMilliseconds(policy.nowEpochMilliseconds)
|
||||
) {
|
||||
return failure("INVALID_ENVELOPE");
|
||||
}
|
||||
if (input.cacheEpoch !== policy.cacheEpoch) {
|
||||
return failure("CACHE_EPOCH_MISMATCH");
|
||||
}
|
||||
if (
|
||||
!Object.hasOwn(policy.topicVersions, input.topic) ||
|
||||
policy.topicVersions[input.topic] !== input.topicVersion
|
||||
) {
|
||||
return failure("TOPIC_REJECTED");
|
||||
}
|
||||
if (
|
||||
input.expiresAt <= policy.nowEpochMilliseconds ||
|
||||
input.emittedAt >
|
||||
policy.nowEpochMilliseconds +
|
||||
CACHE_INVALIDATION_WIRE_LIMITS.maxFutureClockSkewMs
|
||||
) {
|
||||
return failure("EXPIRED");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: Object.freeze({
|
||||
protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION,
|
||||
eventId: input.eventId,
|
||||
sourceId: input.sourceId,
|
||||
sourceEpoch: input.sourceEpoch,
|
||||
sequence: input.sequence,
|
||||
cacheEpoch: input.cacheEpoch,
|
||||
topic: input.topic,
|
||||
topicVersion: input.topicVersion,
|
||||
emittedAt: input.emittedAt,
|
||||
expiresAt: input.expiresAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function isExactWireRecord(
|
||||
value: unknown,
|
||||
): value is Readonly<Record<(typeof WIRE_KEYS)[number], unknown>> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
return (
|
||||
keys.length === WIRE_KEYS.length &&
|
||||
keys.every((key, index) => key === WIRE_KEYS[index])
|
||||
);
|
||||
}
|
||||
|
||||
function isPositiveSafeInteger(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 1
|
||||
);
|
||||
}
|
||||
|
||||
function isEpochMilliseconds(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 0
|
||||
);
|
||||
}
|
||||
|
||||
function failure(
|
||||
reason: CacheInvalidationParseFailureReason,
|
||||
): Extract<CacheInvalidationParseResult, { ok: false }> {
|
||||
return Object.freeze({ ok: false, reason });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* §5.3. The single canonical byte producer for a contract set.
|
||||
*
|
||||
* Node build scripts and the browser runtime share this function. Only the hash
|
||||
* adapter differs (Node `crypto` vs Web Crypto), so a digest can never diverge
|
||||
* because of JSON property order or a locale-sensitive sort.
|
||||
*/
|
||||
|
||||
export type ContractSetPackage = Readonly<{
|
||||
packageId: string;
|
||||
version: string;
|
||||
digest: `sha256:${string}`;
|
||||
runtimeProtocolVersion: 1;
|
||||
sourceRevision: string;
|
||||
}>;
|
||||
|
||||
export const CONTRACT_SET_ALGORITHM = "CA_CONTRACT_SET_V1" as const;
|
||||
|
||||
const HEADER = "CA_FRONTEND_CONTRACT_SET_V1\u0000";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function compareUtf8(left: string, right: string): number {
|
||||
const a = encoder.encode(left);
|
||||
const b = encoder.encode(right);
|
||||
const shared = Math.min(a.length, b.length);
|
||||
for (let index = 0; index < shared; index += 1) {
|
||||
const difference = (a[index] as number) - (b[index] as number);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return a.length - b.length;
|
||||
}
|
||||
|
||||
export function canonicalizeContractSet(
|
||||
packages: readonly ContractSetPackage[],
|
||||
): Uint8Array {
|
||||
const seen = new Set<string>();
|
||||
for (const entry of packages) {
|
||||
if (seen.has(entry.packageId)) {
|
||||
throw new TypeError("Duplicate contract set package identity.");
|
||||
}
|
||||
seen.add(entry.packageId);
|
||||
}
|
||||
|
||||
const sorted = [...packages].sort((left, right) =>
|
||||
compareUtf8(left.packageId, right.packageId),
|
||||
);
|
||||
|
||||
const chunks: Uint8Array[] = [encoder.encode(HEADER)];
|
||||
for (const entry of sorted) {
|
||||
appendString(chunks, entry.packageId);
|
||||
appendString(chunks, entry.version);
|
||||
appendString(chunks, entry.digest);
|
||||
chunks.push(u32be(entry.runtimeProtocolVersion));
|
||||
appendString(chunks, entry.sourceRevision);
|
||||
}
|
||||
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function appendString(chunks: Uint8Array[], value: string): void {
|
||||
const bytes = encoder.encode(value);
|
||||
chunks.push(u32be(bytes.length));
|
||||
chunks.push(bytes);
|
||||
}
|
||||
|
||||
function u32be(value: number): Uint8Array {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) {
|
||||
throw new TypeError("Contract set length prefix is out of range.");
|
||||
}
|
||||
const bytes = new Uint8Array(4);
|
||||
new DataView(bytes.buffer).setUint32(0, value, false);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function toLowerHex(digest: ArrayBuffer | Uint8Array): string {
|
||||
const bytes =
|
||||
digest instanceof Uint8Array ? digest : new Uint8Array(digest);
|
||||
let output = "";
|
||||
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-side digest. Node callers pass their own `crypto.createHash` adapter
|
||||
* through {@link computeContractSetDigestWith}.
|
||||
*/
|
||||
export async function computeContractSetDigest(
|
||||
packages: readonly ContractSetPackage[],
|
||||
): Promise<`sha256:${string}`> {
|
||||
const bytes = canonicalizeContractSet(packages);
|
||||
const buffer = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
bytes.slice().buffer as ArrayBuffer,
|
||||
);
|
||||
return `sha256:${toLowerHex(buffer)}`;
|
||||
}
|
||||
|
||||
export function computeContractSetDigestWith(
|
||||
packages: readonly ContractSetPackage[],
|
||||
sha256: (bytes: Uint8Array) => Uint8Array,
|
||||
): `sha256:${string}` {
|
||||
return `sha256:${toLowerHex(sha256(canonicalizeContractSet(packages)))}`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CONTRACT_SET_ALGORITHM,
|
||||
computeContractSetDigest,
|
||||
type ContractSetPackage,
|
||||
} from "./contract-set-canonical.ts";
|
||||
|
||||
/**
|
||||
* §5. Contract set and release coherence.
|
||||
*
|
||||
* The frontend verifies that the packages compiled into this build match the
|
||||
* packages the release manifest declares. It never negotiates ranges, resolves
|
||||
* `latest`, or infers a provider runtime version.
|
||||
*/
|
||||
|
||||
export type ContractSetFailureCode =
|
||||
| "CONTRACT_SET_SCHEMA_INVALID"
|
||||
| "CONTRACT_SET_ENTRY_INVALID"
|
||||
| "CONTRACT_SET_DUPLICATE_PACKAGE"
|
||||
| "CONTRACT_SET_DIGEST_INVALID"
|
||||
| "CONTRACT_SET_DIGEST_MISMATCH"
|
||||
| "CONTRACT_SET_PACKAGE_MISSING"
|
||||
| "CONTRACT_SET_PACKAGE_UNEXPECTED"
|
||||
| "CONTRACT_SET_VERSION_MISMATCH"
|
||||
| "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED";
|
||||
|
||||
const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
||||
|
||||
export const contractSetPackageSchema = z
|
||||
.object({
|
||||
packageId: z
|
||||
.string()
|
||||
.regex(/^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/),
|
||||
version: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/,
|
||||
),
|
||||
digest: digestSchema,
|
||||
runtimeProtocolVersion: z.literal(1),
|
||||
sourceRevision: z.string().regex(/^[0-9a-f]{7,64}$/),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const contractSetSchema = z
|
||||
.object({
|
||||
setAlgorithm: z.literal(CONTRACT_SET_ALGORITHM),
|
||||
setDigest: digestSchema,
|
||||
packages: z.array(contractSetPackageSchema).max(256),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ContractSet = z.output<typeof contractSetSchema>;
|
||||
|
||||
export type ContractSetVerification =
|
||||
| Readonly<{ ok: true }>
|
||||
| Readonly<{ ok: false; code: ContractSetFailureCode }>;
|
||||
|
||||
/**
|
||||
* §5.5. Every comparison below must hold. There is no precedence rule between
|
||||
* the embedded expectation and the manifest: a disagreement fails the boot.
|
||||
*/
|
||||
export async function verifyContractSet(
|
||||
input: Readonly<{
|
||||
expected: readonly ContractSetPackage[];
|
||||
manifest: ContractSet;
|
||||
expectedSetDigest?: `sha256:${string}`;
|
||||
}>,
|
||||
): Promise<ContractSetVerification> {
|
||||
const manifestIds = new Set<string>();
|
||||
for (const entry of input.manifest.packages) {
|
||||
if (manifestIds.has(entry.packageId)) {
|
||||
return failure("CONTRACT_SET_DUPLICATE_PACKAGE");
|
||||
}
|
||||
manifestIds.add(entry.packageId);
|
||||
if (entry.runtimeProtocolVersion !== 1) {
|
||||
return failure("CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
const expectedById = new Map(
|
||||
input.expected.map((entry) => [entry.packageId, entry] as const),
|
||||
);
|
||||
for (const entry of input.manifest.packages) {
|
||||
if (!expectedById.has(entry.packageId)) {
|
||||
return failure("CONTRACT_SET_PACKAGE_UNEXPECTED");
|
||||
}
|
||||
}
|
||||
for (const entry of input.expected) {
|
||||
const found = input.manifest.packages.find(
|
||||
(candidate) => candidate.packageId === entry.packageId,
|
||||
);
|
||||
if (!found) return failure("CONTRACT_SET_PACKAGE_MISSING");
|
||||
if (
|
||||
found.version !== entry.version ||
|
||||
found.sourceRevision !== entry.sourceRevision
|
||||
) {
|
||||
return failure("CONTRACT_SET_VERSION_MISMATCH");
|
||||
}
|
||||
if (found.digest !== entry.digest) {
|
||||
return failure("CONTRACT_SET_DIGEST_MISMATCH");
|
||||
}
|
||||
}
|
||||
|
||||
let recomputed: `sha256:${string}`;
|
||||
try {
|
||||
recomputed = await computeContractSetDigest(
|
||||
input.manifest.packages as readonly ContractSetPackage[],
|
||||
);
|
||||
} catch {
|
||||
return failure("CONTRACT_SET_DIGEST_INVALID");
|
||||
}
|
||||
if (recomputed !== input.manifest.setDigest) {
|
||||
return failure("CONTRACT_SET_DIGEST_MISMATCH");
|
||||
}
|
||||
|
||||
const expectedDigest =
|
||||
input.expectedSetDigest ?? (await computeContractSetDigest(input.expected));
|
||||
if (expectedDigest !== input.manifest.setDigest) {
|
||||
return failure("CONTRACT_SET_DIGEST_MISMATCH");
|
||||
}
|
||||
|
||||
return Object.freeze({ ok: true as const });
|
||||
}
|
||||
|
||||
function failure(code: ContractSetFailureCode): ContractSetVerification {
|
||||
return Object.freeze({ ok: false as const, code });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Result } from "../application/result.ts";
|
||||
|
||||
export type CursorPage<Value> = Readonly<{
|
||||
items: readonly Value[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
snapshotToken: string | null;
|
||||
}>;
|
||||
|
||||
export type CursorPaginationProfile = Readonly<{
|
||||
profileId: string;
|
||||
maxPages: number;
|
||||
maxTotalItems: number;
|
||||
maxEstimatedBytes: number;
|
||||
maxCursorBytes: number;
|
||||
allowSparsePage: boolean;
|
||||
}>;
|
||||
|
||||
export type CursorPaginationRuntime<Value> = Readonly<{
|
||||
loadAll(context: Readonly<{ signal?: AbortSignal }>): Promise<
|
||||
Result<readonly Value[]>
|
||||
>;
|
||||
}>;
|
||||
@@ -0,0 +1,8 @@
|
||||
export type QueueSizeBucket = "0" | "1-10" | "11-50" | "51+";
|
||||
|
||||
export function queueSizeBucket(size: number): QueueSizeBucket {
|
||||
if (size <= 0) return "0";
|
||||
if (size <= 10) return "1-10";
|
||||
if (size <= 50) return "11-50";
|
||||
return "51+";
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
export const DIAGNOSTIC_LEVELS = Object.freeze([
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
] as const);
|
||||
export { queueSizeBucket } from "./diagnostic-buckets.ts";
|
||||
export type DiagnosticLevel = (typeof DIAGNOSTIC_LEVELS)[number];
|
||||
|
||||
export const DIAGNOSTIC_EVENT_REGISTRY = Object.freeze({
|
||||
"app.boot.failed": Object.freeze({ level: "error" }),
|
||||
"http.request.completed": Object.freeze({ level: "info" }),
|
||||
"cache.operation.failed": Object.freeze({ level: "warn" }),
|
||||
"storage.operation.failed": Object.freeze({ level: "warn" }),
|
||||
"route.changed": Object.freeze({ level: "info" }),
|
||||
"ui.render.failed": Object.freeze({ level: "error" }),
|
||||
"release.mismatch.detected": Object.freeze({ level: "warn" }),
|
||||
"telemetry.delivery.dropped": Object.freeze({ level: "warn" }),
|
||||
});
|
||||
export type DiagnosticEventId = keyof typeof DIAGNOSTIC_EVENT_REGISTRY;
|
||||
|
||||
export const DIAGNOSTIC_CONTEXT_ALLOWLIST = Object.freeze([
|
||||
"app_version",
|
||||
"build_id",
|
||||
"release_id",
|
||||
"active_release_id",
|
||||
"config_schema_version",
|
||||
"api_contract_version",
|
||||
"route_id",
|
||||
"operation_id",
|
||||
"correlation_id",
|
||||
"error_kind",
|
||||
"outcome",
|
||||
"http_status_group",
|
||||
"attempt_count_bucket",
|
||||
"duration_bucket",
|
||||
"component_boundary",
|
||||
"mismatch_kind",
|
||||
"operation",
|
||||
"reason",
|
||||
"queue_size_bucket",
|
||||
] as const);
|
||||
export type DiagnosticContextKey =
|
||||
(typeof DIAGNOSTIC_CONTEXT_ALLOWLIST)[number];
|
||||
export type DiagnosticContext = Readonly<
|
||||
Partial<Record<DiagnosticContextKey, string | number | boolean>>
|
||||
>;
|
||||
|
||||
export type DiagnosticRecordInput = Readonly<{
|
||||
level: DiagnosticLevel;
|
||||
eventId: DiagnosticEventId;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export type DiagnosticRecord = Readonly<{
|
||||
level: DiagnosticLevel;
|
||||
eventId: DiagnosticEventId;
|
||||
timestamp: string;
|
||||
context: DiagnosticContext;
|
||||
}>;
|
||||
|
||||
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/;
|
||||
|
||||
function projectDiagnosticRecordUnsafe(
|
||||
input: DiagnosticRecordInput,
|
||||
now: () => number = Date.now,
|
||||
):
|
||||
| Readonly<{ success: true; record: DiagnosticRecord }>
|
||||
| Readonly<{ success: false; reason: string }> {
|
||||
if (!Object.hasOwn(DIAGNOSTIC_EVENT_REGISTRY, input.eventId)) {
|
||||
return { success: false, reason: "unregistered-event" };
|
||||
}
|
||||
if (!DIAGNOSTIC_LEVELS.includes(input.level)) {
|
||||
return { success: false, reason: "invalid-level" };
|
||||
}
|
||||
const contextEntries = Object.entries(input.context ?? {});
|
||||
if (contextEntries.length > DIAGNOSTIC_CONTEXT_ALLOWLIST.length) {
|
||||
return { success: false, reason: "invalid-context" };
|
||||
}
|
||||
const projected: Partial<
|
||||
Record<DiagnosticContextKey, string | number | boolean>
|
||||
> = {};
|
||||
for (const [key, value] of contextEntries) {
|
||||
if (
|
||||
!DIAGNOSTIC_CONTEXT_ALLOWLIST.includes(key as DiagnosticContextKey)
|
||||
) {
|
||||
return { success: false, reason: "unknown-context" };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (!SAFE_VALUE.test(value)) {
|
||||
return { success: false, reason: "invalid-context" };
|
||||
}
|
||||
projected[key as DiagnosticContextKey] = value;
|
||||
} else if (typeof value === "number" && Number.isFinite(value)) {
|
||||
projected[key as DiagnosticContextKey] = value;
|
||||
} else if (typeof value === "boolean") {
|
||||
projected[key as DiagnosticContextKey] = value;
|
||||
} else {
|
||||
return { success: false, reason: "invalid-context" };
|
||||
}
|
||||
}
|
||||
let timestamp: string;
|
||||
try {
|
||||
timestamp = new Date(now()).toISOString();
|
||||
} catch {
|
||||
timestamp = new Date(0).toISOString();
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
record: Object.freeze({
|
||||
level: input.level,
|
||||
eventId: input.eventId,
|
||||
timestamp,
|
||||
context: Object.freeze(projected),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectDiagnosticRecord(
|
||||
input: DiagnosticRecordInput,
|
||||
now: () => number = Date.now,
|
||||
): ReturnType<typeof projectDiagnosticRecordUnsafe> {
|
||||
try {
|
||||
return projectDiagnosticRecordUnsafe(input, now);
|
||||
} catch {
|
||||
return { success: false, reason: "serialization-failure" };
|
||||
}
|
||||
}
|
||||
|
||||
export function safeErrorKind(error: unknown): string {
|
||||
try {
|
||||
if (error && typeof error === "object") {
|
||||
const record = error as Readonly<Record<string, unknown>>;
|
||||
if (
|
||||
typeof record.kind === "string" &&
|
||||
/^[A-Z][A-Z0-9_]{0,63}$/.test(record.kind)
|
||||
) {
|
||||
return record.kind;
|
||||
}
|
||||
if (
|
||||
typeof record.name === "string" &&
|
||||
/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(record.name)
|
||||
) {
|
||||
const normalized = record.name
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toUpperCase();
|
||||
return /^[A-Z][A-Z0-9_]{0,63}$/.test(normalized)
|
||||
? normalized
|
||||
: "UNKNOWN_FAILURE";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return "UNKNOWN_FAILURE";
|
||||
}
|
||||
return "UNKNOWN_FAILURE";
|
||||
}
|
||||
|
||||
export function statusGroup(status: number | undefined): string {
|
||||
return typeof status === "number" && Number.isFinite(status)
|
||||
? `${Math.max(0, Math.min(9, Math.floor(status / 100)))}xx`
|
||||
: "none";
|
||||
}
|
||||
|
||||
export function attemptBucket(attemptCount: number): string {
|
||||
if (attemptCount <= 1) return "1";
|
||||
if (attemptCount === 2) return "2";
|
||||
if (attemptCount <= 4) return "3-4";
|
||||
return "5+";
|
||||
}
|
||||
|
||||
export function durationBucket(durationMs: number): string {
|
||||
if (!Number.isFinite(durationMs) || durationMs < 0) return "unknown";
|
||||
if (durationMs < 100) return "lt100ms";
|
||||
if (durationMs < 500) return "100-499ms";
|
||||
if (durationMs < 2_000) return "500-1999ms";
|
||||
return "gte2000ms";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* §6.3. Case-insensitive key fragments that can never appear in a client
|
||||
* configuration document.
|
||||
*/
|
||||
const FORBIDDEN_CONFIG_NAME_FRAGMENTS = Object.freeze([
|
||||
"PASSWORD",
|
||||
"SECRET",
|
||||
"TOKEN",
|
||||
"PRIVATE_KEY",
|
||||
"CLIENT_SECRET",
|
||||
"ACCESS_KEY",
|
||||
"REFRESH_TOKEN",
|
||||
"COOKIE",
|
||||
"AUTHORIZATION",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Exact top-level keys whose fragment match is a semantic enum name, not a
|
||||
* credential. The allowlist is exact-key only; it is never applied to arbitrary
|
||||
* nested keys.
|
||||
*/
|
||||
const SEMANTIC_KEY_ALLOWLIST = Object.freeze(
|
||||
new Set(["AUTH_MODE", "TELEMETRY_ENABLED"]),
|
||||
);
|
||||
|
||||
export type EnvironmentPhase = "build" | "runtime";
|
||||
export type EnvironmentDefinition = Readonly<{
|
||||
phase: EnvironmentPhase;
|
||||
classification: string;
|
||||
required: boolean;
|
||||
defaultValue: unknown;
|
||||
}>;
|
||||
|
||||
export const ENV_REGISTRY = Object.freeze({
|
||||
VITE_BUILD_ID: build("public-metadata", true, null),
|
||||
VITE_COMMIT_SHA: build("public-metadata", false, "local"),
|
||||
VITE_ROUTER_BASE_PATH: build("compile-time", true, "/"),
|
||||
VITE_RUNTIME_CONFIG_URL: build("compile-time", true, "/config.json"),
|
||||
APP_ENV: runtime("public", true, null),
|
||||
API_BASE_URL: runtime("public-sensitive", true, null),
|
||||
REQUEST_TIMEOUT_MS: runtime("public", false, 10_000),
|
||||
MAX_RETRY_ATTEMPTS: runtime("public", false, 2),
|
||||
TELEMETRY_ENABLED: runtime("public", true, false),
|
||||
TELEMETRY_ENDPOINT: runtime("public-sensitive", false, null),
|
||||
AUTH_MODE: runtime("public", true, "external"),
|
||||
CONFIG_SCHEMA_VERSION: runtime("public", true, null),
|
||||
RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"),
|
||||
// §3.5: overrides may only disable an installed capability, never enable one.
|
||||
CAPABILITY_OVERRIDES: runtime("public", false, null),
|
||||
});
|
||||
|
||||
function build(
|
||||
classification: string,
|
||||
required: boolean,
|
||||
defaultValue: unknown,
|
||||
): EnvironmentDefinition {
|
||||
return Object.freeze({ phase: "build", classification, required, defaultValue });
|
||||
}
|
||||
|
||||
function runtime(
|
||||
classification: string,
|
||||
required: boolean,
|
||||
defaultValue: unknown,
|
||||
): EnvironmentDefinition {
|
||||
return Object.freeze({ phase: "runtime", classification, required, defaultValue });
|
||||
}
|
||||
|
||||
export function assertSafeConfigNames(
|
||||
config: Readonly<Record<string, unknown>>,
|
||||
depth = 0,
|
||||
): void {
|
||||
if (depth > 4) {
|
||||
throw new Error("Client configuration nesting exceeds its bound");
|
||||
}
|
||||
for (const [name, value] of Object.entries(config)) {
|
||||
const allowlisted = depth === 0 && SEMANTIC_KEY_ALLOWLIST.has(name);
|
||||
if (!allowlisted && isForbiddenConfigName(name)) {
|
||||
throw new Error(`Forbidden client configuration key: ${name}`);
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
assertSafeConfigNames(value as Record<string, unknown>, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isForbiddenConfigName(name: string): boolean {
|
||||
const upper = name.toUpperCase();
|
||||
return FORBIDDEN_CONFIG_NAME_FRAGMENTS.some((fragment) =>
|
||||
upper.includes(fragment),
|
||||
);
|
||||
}
|
||||
|
||||
export type BuildEnvironment = Readonly<{
|
||||
VITE_BUILD_ID?: string;
|
||||
VITE_COMMIT_SHA?: string;
|
||||
VITE_ROUTER_BASE_PATH?: string;
|
||||
VITE_RUNTIME_CONFIG_URL?: string;
|
||||
}>;
|
||||
|
||||
export function getBuildConfig(
|
||||
environment: BuildEnvironment = import.meta.env as BuildEnvironment,
|
||||
) {
|
||||
const buildId = environment.VITE_BUILD_ID || "local-build";
|
||||
const commitSha = environment.VITE_COMMIT_SHA || "local";
|
||||
const routerBasePath = environment.VITE_ROUTER_BASE_PATH || "/";
|
||||
const runtimeConfigUrl = environment.VITE_RUNTIME_CONFIG_URL || "/config.json";
|
||||
|
||||
return Object.freeze({ buildId, commitSha, routerBasePath, runtimeConfigUrl });
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
const DROP_SENSITIVE = Object.freeze([
|
||||
"cause",
|
||||
"body",
|
||||
"headers",
|
||||
"authorization",
|
||||
"url",
|
||||
"query",
|
||||
"stack",
|
||||
"storageValue",
|
||||
] as const);
|
||||
|
||||
export type ErrorAction =
|
||||
| "retry"
|
||||
| "reauth"
|
||||
| "navigate"
|
||||
| "reload-once"
|
||||
| "contact-support"
|
||||
| "none";
|
||||
|
||||
export type ErrorDefinition<Kind extends string> = Readonly<{
|
||||
kind: Kind;
|
||||
defaultRetryable: boolean;
|
||||
severity: string;
|
||||
userMessageKey: string;
|
||||
action: ErrorAction;
|
||||
telemetryEvent: string;
|
||||
redaction: readonly string[];
|
||||
}>;
|
||||
|
||||
const row = <Kind extends string>(
|
||||
kind: Kind,
|
||||
defaultRetryable: boolean,
|
||||
severity: string,
|
||||
action: ErrorAction,
|
||||
telemetryEvent = "api.request.failed",
|
||||
): ErrorDefinition<Kind> =>
|
||||
Object.freeze({
|
||||
kind,
|
||||
defaultRetryable,
|
||||
severity,
|
||||
userMessageKey: `error.${kind.toLowerCase()}`,
|
||||
action,
|
||||
telemetryEvent,
|
||||
redaction: DROP_SENSITIVE,
|
||||
});
|
||||
|
||||
export const ERROR_REGISTRY = Object.freeze({
|
||||
NETWORK_UNREACHABLE: row("NETWORK_UNREACHABLE", true, "warning", "retry"),
|
||||
REQUEST_TIMEOUT: row("REQUEST_TIMEOUT", true, "warning", "retry"),
|
||||
REQUEST_ABORTED: row("REQUEST_ABORTED", false, "info", "none"),
|
||||
CONTENT_TYPE_MISMATCH: row(
|
||||
"CONTENT_TYPE_MISMATCH",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
),
|
||||
MALFORMED_JSON: row("MALFORMED_JSON", false, "error", "contact-support"),
|
||||
RESPONSE_BODY_LIMIT: row(
|
||||
"RESPONSE_BODY_LIMIT",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
),
|
||||
ENVELOPE_MISMATCH: row("ENVELOPE_MISMATCH", false, "error", "contact-support"),
|
||||
SCHEMA_MISMATCH: row("SCHEMA_MISMATCH", false, "error", "contact-support"),
|
||||
MAPPING_CONTRACT_VIOLATION: row(
|
||||
"MAPPING_CONTRACT_VIOLATION",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
),
|
||||
RESULT_LIMIT_EXCEEDED: row(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
),
|
||||
SCOPE_GENERATION_CHANGED: row(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
false,
|
||||
"info",
|
||||
"none",
|
||||
),
|
||||
IDENTITY_INTERN_LIMIT_EXCEEDED: row(
|
||||
"IDENTITY_INTERN_LIMIT_EXCEEDED",
|
||||
false,
|
||||
"warning",
|
||||
"retry",
|
||||
),
|
||||
DUPLICATE_IN_FLIGHT: row(
|
||||
"DUPLICATE_IN_FLIGHT",
|
||||
false,
|
||||
"info",
|
||||
"none",
|
||||
),
|
||||
PAGINATION_CONTRACT_VIOLATION: row(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
),
|
||||
AUTH_REQUIRED: row("AUTH_REQUIRED", false, "info", "reauth"),
|
||||
AUTH_INTEGRATION_FAILURE: row(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
),
|
||||
FORBIDDEN: row("FORBIDDEN", false, "warning", "navigate"),
|
||||
NOT_FOUND: row("NOT_FOUND", false, "info", "navigate"),
|
||||
CONFLICT: row("CONFLICT", false, "warning", "retry"),
|
||||
VALIDATION_REJECTED: row("VALIDATION_REJECTED", false, "info", "none"),
|
||||
UNKNOWN_CLIENT_FAILURE: row(
|
||||
"UNKNOWN_CLIENT_FAILURE",
|
||||
false,
|
||||
"warning",
|
||||
"contact-support",
|
||||
),
|
||||
RATE_LIMITED: row("RATE_LIMITED", true, "warning", "retry"),
|
||||
SERVER_FAILURE: row("SERVER_FAILURE", true, "error", "retry"),
|
||||
CHUNK_LOAD_FAILURE: row(
|
||||
"CHUNK_LOAD_FAILURE",
|
||||
false,
|
||||
"error",
|
||||
"reload-once",
|
||||
"release.mismatch.detected",
|
||||
),
|
||||
BOOT_CONFIG_FAILURE: row(
|
||||
"BOOT_CONFIG_FAILURE",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
"app.boot.failed",
|
||||
),
|
||||
RELEASE_MANIFEST_FAILURE: row(
|
||||
"RELEASE_MANIFEST_FAILURE",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
"app.boot.failed",
|
||||
),
|
||||
DEPLOY_MISMATCH: row(
|
||||
"DEPLOY_MISMATCH",
|
||||
false,
|
||||
"error",
|
||||
"reload-once",
|
||||
"release.mismatch.detected",
|
||||
),
|
||||
BUILD_MISMATCH: row(
|
||||
"BUILD_MISMATCH",
|
||||
false,
|
||||
"error",
|
||||
"reload-once",
|
||||
"release.mismatch.detected",
|
||||
),
|
||||
CONFIG_MISMATCH: row(
|
||||
"CONFIG_MISMATCH",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
"app.boot.failed",
|
||||
),
|
||||
API_CONTRACT_MISMATCH: row(
|
||||
"API_CONTRACT_MISMATCH",
|
||||
false,
|
||||
"error",
|
||||
"contact-support",
|
||||
"app.boot.failed",
|
||||
),
|
||||
RELEASE_MISMATCH: row(
|
||||
"RELEASE_MISMATCH",
|
||||
false,
|
||||
"error",
|
||||
"reload-once",
|
||||
"release.mismatch.detected",
|
||||
),
|
||||
ASSET_MISMATCH: row(
|
||||
"ASSET_MISMATCH",
|
||||
false,
|
||||
"error",
|
||||
"reload-once",
|
||||
"release.mismatch.detected",
|
||||
),
|
||||
STORAGE_UNAVAILABLE: row(
|
||||
"STORAGE_UNAVAILABLE",
|
||||
false,
|
||||
"warning",
|
||||
"none",
|
||||
"storage.operation.failed",
|
||||
),
|
||||
STORAGE_QUOTA_EXCEEDED: row(
|
||||
"STORAGE_QUOTA_EXCEEDED",
|
||||
false,
|
||||
"warning",
|
||||
"none",
|
||||
"storage.operation.failed",
|
||||
),
|
||||
RENDER_FAILURE: row(
|
||||
"RENDER_FAILURE",
|
||||
false,
|
||||
"error",
|
||||
"reload-once",
|
||||
"ui.render.failed",
|
||||
),
|
||||
TELEMETRY_FAILURE: row(
|
||||
"TELEMETRY_FAILURE",
|
||||
false,
|
||||
"info",
|
||||
"none",
|
||||
"telemetry.delivery.dropped",
|
||||
),
|
||||
QUERY_CACHE_FAILURE: row(
|
||||
"QUERY_CACHE_FAILURE",
|
||||
false,
|
||||
"error",
|
||||
"retry",
|
||||
"query.cache.failed",
|
||||
),
|
||||
UNKNOWN_FAILURE: row("UNKNOWN_FAILURE", false, "error", "contact-support"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Every failure crossing an application input boundary must use one of the
|
||||
* registry-owned kinds. Adapters may accept untrusted backend codes, but must
|
||||
* map those codes to this closed vocabulary before returning.
|
||||
*/
|
||||
export type FailureKind = keyof typeof ERROR_REGISTRY;
|
||||
|
||||
export type ValidationIssue = Readonly<{ path: string; code: string }>;
|
||||
|
||||
export type FailureEffectCertainty =
|
||||
| "NOT_APPLICABLE"
|
||||
| "NOT_STARTED"
|
||||
| "NOT_APPLIED"
|
||||
| "APPLIED_CONFIRMED"
|
||||
| "MAYBE_APPLIED";
|
||||
|
||||
export type AppFailure = Readonly<{
|
||||
kind: FailureKind;
|
||||
code: string;
|
||||
httpStatus?: number;
|
||||
retryable: boolean;
|
||||
operationId: string;
|
||||
attemptCount: number;
|
||||
requestId?: string;
|
||||
traceId?: string;
|
||||
retryAfterMs?: number;
|
||||
effect?: FailureEffectCertainty;
|
||||
validationIssues?: readonly ValidationIssue[];
|
||||
userMessageKey: string;
|
||||
action: ErrorAction;
|
||||
causeClass?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Backward-compatible transport-facing name. New application and presentation
|
||||
* code should prefer AppFailure.
|
||||
*
|
||||
*/
|
||||
export type ApiFailure = AppFailure;
|
||||
|
||||
export type FailureDetails = Readonly<{
|
||||
code?: string;
|
||||
httpStatus?: number;
|
||||
requestId?: string;
|
||||
traceId?: string;
|
||||
retryAfterMs?: number;
|
||||
effect?: FailureEffectCertainty;
|
||||
validationIssues?: readonly ValidationIssue[];
|
||||
causeClass?: string;
|
||||
}>;
|
||||
|
||||
export function createFailure(
|
||||
kind: FailureKind,
|
||||
operationId: string,
|
||||
attempt: number,
|
||||
details: FailureDetails = {},
|
||||
): AppFailure {
|
||||
const definition: ErrorDefinition<FailureKind> = ERROR_REGISTRY[kind];
|
||||
return Object.freeze({
|
||||
kind: definition.kind,
|
||||
code: typeof details.code === "string" ? details.code : definition.kind,
|
||||
retryable:
|
||||
details.effect === "MAYBE_APPLIED" ||
|
||||
details.effect === "APPLIED_CONFIRMED"
|
||||
? false
|
||||
: definition.defaultRetryable,
|
||||
operationId,
|
||||
attemptCount: Math.max(1, attempt + 1),
|
||||
...(Number.isInteger(details.httpStatus)
|
||||
? { httpStatus: details.httpStatus }
|
||||
: {}),
|
||||
...(typeof details.requestId === "string" ? { requestId: details.requestId } : {}),
|
||||
...(typeof details.traceId === "string" ? { traceId: details.traceId } : {}),
|
||||
...(typeof details.retryAfterMs === "number"
|
||||
? { retryAfterMs: details.retryAfterMs }
|
||||
: {}),
|
||||
...(details.effect === undefined ? {} : { effect: details.effect }),
|
||||
...(Array.isArray(details.validationIssues)
|
||||
? {
|
||||
validationIssues: Object.freeze(
|
||||
details.validationIssues
|
||||
.filter(
|
||||
(issue) =>
|
||||
issue &&
|
||||
typeof issue === "object" &&
|
||||
typeof issue.path === "string" &&
|
||||
typeof issue.code === "string",
|
||||
)
|
||||
.slice(0, 50)
|
||||
.map((issue) =>
|
||||
Object.freeze({ path: issue.path, code: issue.code }),
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(typeof details.causeClass === "string"
|
||||
? { causeClass: details.causeClass }
|
||||
: {}),
|
||||
userMessageKey: definition.userMessageKey,
|
||||
action:
|
||||
details.effect === "MAYBE_APPLIED"
|
||||
? "contact-support"
|
||||
: details.effect === "APPLIED_CONFIRMED"
|
||||
? "none"
|
||||
: definition.action,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds controller-owned mutation effect knowledge without weakening the
|
||||
* fail-safe handling required for an unknown server-side outcome.
|
||||
*/
|
||||
export function withFailureEffect(
|
||||
failure: AppFailure,
|
||||
effect: Exclude<FailureEffectCertainty, "NOT_APPLICABLE">,
|
||||
): AppFailure {
|
||||
return Object.freeze({
|
||||
...failure,
|
||||
effect,
|
||||
...(effect === "MAYBE_APPLIED"
|
||||
? { retryable: false as const, action: "contact-support" as const }
|
||||
: effect === "APPLIED_CONFIRMED"
|
||||
? { retryable: false as const, action: "none" as const }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects an untrusted 422 details payload into the only validation metadata
|
||||
* allowed to cross the HTTP boundary. Backend copy and additional values are
|
||||
* deliberately discarded.
|
||||
*
|
||||
*/
|
||||
export function safeValidationIssues(
|
||||
value: unknown,
|
||||
): readonly ValidationIssue[] {
|
||||
if (!value || typeof value !== "object") return Object.freeze([]);
|
||||
const candidate = value as Readonly<{
|
||||
issues?: unknown;
|
||||
fieldErrors?: unknown;
|
||||
}>;
|
||||
const issues = Array.isArray(candidate.issues)
|
||||
? candidate.issues
|
||||
: Array.isArray(candidate.fieldErrors)
|
||||
? candidate.fieldErrors
|
||||
: [];
|
||||
return Object.freeze(
|
||||
issues
|
||||
.filter(
|
||||
(issue): issue is ValidationIssue =>
|
||||
issue &&
|
||||
typeof issue === "object" &&
|
||||
"path" in issue &&
|
||||
"code" in issue &&
|
||||
typeof issue.path === "string" &&
|
||||
typeof issue.code === "string" &&
|
||||
issue.path.length <= 120 &&
|
||||
issue.code.length <= 80,
|
||||
)
|
||||
.slice(0, 50)
|
||||
.map((issue) =>
|
||||
Object.freeze({
|
||||
path: issue.path,
|
||||
code: issue.code,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function kindForStatus(status: number): FailureKind {
|
||||
if (status === 401) return "AUTH_REQUIRED";
|
||||
if (status === 403) return "FORBIDDEN";
|
||||
if (status === 404) return "NOT_FOUND";
|
||||
if (status === 409) return "CONFLICT";
|
||||
if (status === 422) return "VALIDATION_REJECTED";
|
||||
if (status === 429) return "RATE_LIMITED";
|
||||
if (status >= 500) return "SERVER_FAILURE";
|
||||
if (status >= 400) return "UNKNOWN_CLIENT_FAILURE";
|
||||
return "ENVELOPE_MISMATCH";
|
||||
}
|
||||
|
||||
/**
|
||||
* Total catch-all that intentionally discards the thrown value.
|
||||
*
|
||||
*/
|
||||
export function normalizeUnknownFailure(
|
||||
value: unknown,
|
||||
context: Readonly<{ operationId?: string; attempt?: number }> = {},
|
||||
): AppFailure {
|
||||
const causeClass =
|
||||
value instanceof Error
|
||||
? value.name
|
||||
: value === null
|
||||
? "null"
|
||||
: typeof value;
|
||||
|
||||
return createFailure(
|
||||
"UNKNOWN_FAILURE",
|
||||
context.operationId ?? "UNKNOWN_OPERATION",
|
||||
context.attempt ?? 0,
|
||||
{ code: "UNKNOWN_FAILURE", causeClass },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* External contract package consumer boundary (§4).
|
||||
*
|
||||
* This repository does not own OpenAPI/AsyncAPI source, operation semantics,
|
||||
* Problem Details meaning or event payload schemas. It owns only the normalized
|
||||
* descriptor interface, the runtime validator protocol, and the bounds it
|
||||
* applies before a contribution may be composed.
|
||||
*/
|
||||
|
||||
/** §7.3 hard ceilings. A contribution may lower these, never raise them. */
|
||||
export const HTTP_EXECUTION_CEILINGS = Object.freeze({
|
||||
defaultRequestBytes: 262_144,
|
||||
hardRequestBytes: 1_048_576,
|
||||
defaultResponseBytes: 1_048_576,
|
||||
hardResponseBytes: 8_388_608,
|
||||
problemResponseBytes: 65_536,
|
||||
pathTemplateBytes: 512,
|
||||
encodedQueryBytes: 8_192,
|
||||
examinedHeaderValueBytes: 8_192,
|
||||
defaultTotalDeadlineMs: 10_000,
|
||||
hardTotalDeadlineMs: 60_000,
|
||||
hardRetryCount: 2,
|
||||
finalUrlBytes: 16_384,
|
||||
});
|
||||
|
||||
export type RuntimeValidationIssue = Readonly<{
|
||||
path: readonly (string | number)[];
|
||||
code: string;
|
||||
}>;
|
||||
|
||||
export type RuntimeValidationResult<T> =
|
||||
| Readonly<{ success: true; data: T }>
|
||||
| Readonly<{ success: false; issues: readonly RuntimeValidationIssue[] }>;
|
||||
|
||||
export interface RuntimeValidator<T> {
|
||||
readonly schemaId: string;
|
||||
safeParse(value: unknown): RuntimeValidationResult<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A validator invocation never escapes as a native throw. `THROWN` is the
|
||||
* `CONTRACT_RUNTIME_FAILURE` signal; `false` success is the ordinary
|
||||
* `CONTRACT_VALUE_INVALID` signal.
|
||||
*/
|
||||
export type ValidatorInvocation<T> =
|
||||
| Readonly<{ outcome: "VALID"; value: T }>
|
||||
| Readonly<{ outcome: "INVALID"; issues: readonly RuntimeValidationIssue[] }>
|
||||
| Readonly<{ outcome: "THROWN" }>;
|
||||
|
||||
export function invokeValidator<T>(
|
||||
validator: RuntimeValidator<T>,
|
||||
value: unknown,
|
||||
): ValidatorInvocation<T> {
|
||||
let result: RuntimeValidationResult<T>;
|
||||
try {
|
||||
result = validator.safeParse(value);
|
||||
} catch {
|
||||
return Object.freeze({ outcome: "THROWN" as const });
|
||||
}
|
||||
if (!result || typeof result !== "object" || !("success" in result)) {
|
||||
return Object.freeze({ outcome: "THROWN" as const });
|
||||
}
|
||||
if (result.success) {
|
||||
return Object.freeze({ outcome: "VALID" as const, value: result.data });
|
||||
}
|
||||
return Object.freeze({
|
||||
outcome: "INVALID" as const,
|
||||
issues: Object.freeze([...(result.issues ?? [])]),
|
||||
});
|
||||
}
|
||||
|
||||
export type MappingResult<T> =
|
||||
| Readonly<{ ok: true; value: T }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
error: Readonly<{ kind: "MAPPING_CONTRACT_VIOLATION"; code: string }>;
|
||||
}>;
|
||||
|
||||
export function mappingViolation(code: string): MappingResult<never> {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
error: Object.freeze({
|
||||
kind: "MAPPING_CONTRACT_VIOLATION" as const,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export type CommandRecoveryDescriptor = Readonly<{
|
||||
mode: "IDEMPOTENCY_REPLAY" | "INSPECT_OPERATION";
|
||||
operationIdentityField: string;
|
||||
inspectOperationId?: string;
|
||||
}>;
|
||||
|
||||
export type CommandEffectClassification =
|
||||
| "NOT_APPLIED"
|
||||
| "APPLIED_CONFIRMED"
|
||||
| "MAYBE_APPLIED";
|
||||
|
||||
export interface CommandEffectDescriptor<Problem> {
|
||||
readonly successEffect: "APPLIED_CONFIRMED";
|
||||
classifyProblem(
|
||||
input: Readonly<{ status: number; problem: Problem }>,
|
||||
): CommandEffectClassification;
|
||||
}
|
||||
|
||||
export type HttpMethod =
|
||||
| "GET"
|
||||
| "HEAD"
|
||||
| "POST"
|
||||
| "PUT"
|
||||
| "PATCH"
|
||||
| "DELETE";
|
||||
|
||||
export type RetrySemantics = "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER";
|
||||
|
||||
export interface HttpExecutionPolicy {
|
||||
readonly policyId: string;
|
||||
readonly requestByteLimit: number;
|
||||
readonly responseByteLimit: number;
|
||||
readonly totalDeadlineMs: number;
|
||||
readonly retryBudget: 0 | 1 | 2;
|
||||
readonly authProfileId: string;
|
||||
readonly diagnosticsOperation: string;
|
||||
}
|
||||
|
||||
export interface InstalledHttpContract<Input, WireOutput, Problem> {
|
||||
readonly contract: Readonly<{
|
||||
operationId: string;
|
||||
method: HttpMethod;
|
||||
pathTemplate: string;
|
||||
inputValidator: RuntimeValidator<Input>;
|
||||
outputValidator: RuntimeValidator<WireOutput>;
|
||||
problemValidator: RuntimeValidator<Problem>;
|
||||
acceptedStatuses: readonly number[];
|
||||
emptyBodyStatuses: readonly number[];
|
||||
retrySemantics: RetrySemantics;
|
||||
requestBody: "NONE" | "JSON";
|
||||
responseBody: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE";
|
||||
commandRecovery: CommandRecoveryDescriptor | null;
|
||||
commandEffect: CommandEffectDescriptor<Problem> | null;
|
||||
/**
|
||||
* Descriptor-owned projection from canonical application input to the wire
|
||||
* request. The frontend never re-derives method, path or query semantics.
|
||||
*/
|
||||
projectRequest(input: Input): HttpRequestProjection;
|
||||
}>;
|
||||
readonly frontend: HttpExecutionPolicy;
|
||||
}
|
||||
|
||||
export type HttpRequestProjection = Readonly<{
|
||||
/** Ordered path placeholder values keyed by descriptor placeholder name. */
|
||||
pathValues: Readonly<Record<string, string>>;
|
||||
/** Descriptor-generated query entry order; array encoding is descriptor-owned. */
|
||||
queryEntries: readonly (readonly [string, string])[];
|
||||
/** Canonical JSON body value, or `null` when `requestBody` is `NONE`. */
|
||||
body: unknown;
|
||||
}>;
|
||||
|
||||
export interface InstalledEventContract<Envelope, Payload> {
|
||||
readonly eventType: string;
|
||||
readonly envelopeValidator: RuntimeValidator<Envelope>;
|
||||
readonly payloadValidator: RuntimeValidator<Payload>;
|
||||
}
|
||||
|
||||
export interface InstalledContractPackageIdentity {
|
||||
readonly packageId: string;
|
||||
readonly version: string;
|
||||
readonly digest: `sha256:${string}`;
|
||||
readonly runtimeProtocolVersion: 1;
|
||||
readonly sourceRevision: string;
|
||||
}
|
||||
|
||||
export type ContractContributionSource =
|
||||
| Readonly<{
|
||||
kind: "EXTERNAL_PACKAGE";
|
||||
package: InstalledContractPackageIdentity;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "TEMPLATE_FIXTURE";
|
||||
fixtureId: "REFERENCE_FEATURE_V1";
|
||||
revision: 1;
|
||||
}>;
|
||||
|
||||
export interface InstalledContractContribution {
|
||||
readonly contributionId: string;
|
||||
readonly featureId: string;
|
||||
readonly source: ContractContributionSource;
|
||||
readonly http: readonly InstalledHttpContract<unknown, unknown, unknown>[];
|
||||
readonly events: readonly InstalledEventContract<unknown, unknown>[];
|
||||
}
|
||||
|
||||
export type ContractCompositionFailureCode =
|
||||
| "CONTRACT_CONTRIBUTION_INVALID"
|
||||
| "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED";
|
||||
|
||||
export class ContractContributionError extends Error {
|
||||
readonly code: ContractCompositionFailureCode;
|
||||
readonly reason: string;
|
||||
|
||||
constructor(reason: string, code: ContractCompositionFailureCode = "CONTRACT_CONTRIBUTION_INVALID") {
|
||||
super("Installed contract contribution is not composable");
|
||||
this.name = "ContractContributionError";
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// §4.9 exact validation vocabulary.
|
||||
const FEATURE_ID = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9._-]{0,127}$/;
|
||||
const PACKAGE_ID =
|
||||
/^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/;
|
||||
const SEM_VER =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
||||
const SOURCE_REVISION = /^[0-9a-f]{7,64}$/;
|
||||
const OPERATION_ID = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
|
||||
/** Whitespace and C0/C1 control characters are rejected in an event type. */
|
||||
function hasControlOrSpace(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code <= 0x20 || (code >= 0x7f && code <= 0x9f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return encoder.encode(value).byteLength;
|
||||
}
|
||||
|
||||
function fail(reason: string): never {
|
||||
throw new ContractContributionError(reason);
|
||||
}
|
||||
|
||||
function assertPackageIdentity(
|
||||
identity: InstalledContractPackageIdentity,
|
||||
featureId: string,
|
||||
): void {
|
||||
if (!identity || typeof identity !== "object") {
|
||||
fail(`${featureId}: package identity`);
|
||||
}
|
||||
if (identity.runtimeProtocolVersion !== 1) {
|
||||
throw new ContractContributionError(
|
||||
`${featureId}: runtime protocol version must be exactly 1`,
|
||||
"CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED",
|
||||
);
|
||||
}
|
||||
if (!PACKAGE_ID.test(identity.packageId)) fail(`${featureId}: packageId`);
|
||||
if (
|
||||
typeof identity.version !== "string" ||
|
||||
identity.version !== identity.version.trim() ||
|
||||
identity.version.startsWith("v") ||
|
||||
!SEM_VER.test(identity.version)
|
||||
) {
|
||||
fail(`${featureId}: package version must be exact SemVer`);
|
||||
}
|
||||
if (!DIGEST.test(identity.digest)) fail(`${featureId}: package digest`);
|
||||
if (!SOURCE_REVISION.test(identity.sourceRevision)) {
|
||||
fail(`${featureId}: package sourceRevision`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidator(
|
||||
validator: RuntimeValidator<unknown>,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!validator ||
|
||||
typeof validator.safeParse !== "function" ||
|
||||
typeof validator.schemaId !== "string" ||
|
||||
validator.schemaId.length === 0
|
||||
) {
|
||||
fail(`${label}: runtime validator with non-empty schemaId is required`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStatuses(
|
||||
statuses: readonly number[],
|
||||
label: string,
|
||||
): void {
|
||||
if (!Array.isArray(statuses)) fail(`${label}: status array required`);
|
||||
if (statuses.length < 1 || statuses.length > 32) {
|
||||
fail(`${label}: 1..32 statuses required`);
|
||||
}
|
||||
for (let index = 0; index < statuses.length; index += 1) {
|
||||
const status = statuses[index] as number;
|
||||
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
||||
fail(`${label}: status out of range`);
|
||||
}
|
||||
const previous = statuses[index - 1];
|
||||
if (index > 0 && previous !== undefined && status <= previous) {
|
||||
fail(`${label}: statuses must be sorted and unique`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExecutionPolicy(
|
||||
policy: HttpExecutionPolicy,
|
||||
label: string,
|
||||
): void {
|
||||
const ceilings = HTTP_EXECUTION_CEILINGS;
|
||||
if (
|
||||
!policy ||
|
||||
typeof policy.policyId !== "string" ||
|
||||
policy.policyId.length === 0 ||
|
||||
typeof policy.authProfileId !== "string" ||
|
||||
policy.authProfileId.length === 0 ||
|
||||
typeof policy.diagnosticsOperation !== "string" ||
|
||||
policy.diagnosticsOperation.length === 0
|
||||
) {
|
||||
fail(`${label}: frontend execution policy identity`);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.requestByteLimit) ||
|
||||
policy.requestByteLimit < 0 ||
|
||||
policy.requestByteLimit > ceilings.hardRequestBytes
|
||||
) {
|
||||
fail(`${label}: requestByteLimit exceeds the hard ceiling`);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.responseByteLimit) ||
|
||||
policy.responseByteLimit < 1 ||
|
||||
policy.responseByteLimit > ceilings.hardResponseBytes
|
||||
) {
|
||||
fail(`${label}: responseByteLimit exceeds the hard ceiling`);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.totalDeadlineMs) ||
|
||||
policy.totalDeadlineMs < 1 ||
|
||||
policy.totalDeadlineMs > ceilings.hardTotalDeadlineMs
|
||||
) {
|
||||
fail(`${label}: totalDeadlineMs exceeds the hard ceiling`);
|
||||
}
|
||||
if (
|
||||
policy.retryBudget !== 0 &&
|
||||
policy.retryBudget !== 1 &&
|
||||
policy.retryBudget !== 2
|
||||
) {
|
||||
fail(`${label}: retryBudget must be 0, 1 or 2`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertHttpContract(
|
||||
installed: InstalledHttpContract<unknown, unknown, unknown>,
|
||||
featureId: string,
|
||||
): void {
|
||||
const contract = installed?.contract;
|
||||
if (!contract || typeof contract !== "object") {
|
||||
fail(`${featureId}: http contract descriptor missing`);
|
||||
}
|
||||
const label = `${featureId}/${String(contract.operationId)}`;
|
||||
|
||||
if (
|
||||
typeof contract.operationId !== "string" ||
|
||||
!OPERATION_ID.test(contract.operationId)
|
||||
) {
|
||||
fail(`${label}: operationId`);
|
||||
}
|
||||
if (
|
||||
contract.method !== "GET" &&
|
||||
contract.method !== "HEAD" &&
|
||||
contract.method !== "POST" &&
|
||||
contract.method !== "PUT" &&
|
||||
contract.method !== "PATCH" &&
|
||||
contract.method !== "DELETE"
|
||||
) {
|
||||
fail(`${label}: method`);
|
||||
}
|
||||
if (
|
||||
typeof contract.pathTemplate !== "string" ||
|
||||
!contract.pathTemplate.startsWith("/") ||
|
||||
utf8Bytes(contract.pathTemplate) > HTTP_EXECUTION_CEILINGS.pathTemplateBytes ||
|
||||
contract.pathTemplate.includes("?") ||
|
||||
contract.pathTemplate.includes("#")
|
||||
) {
|
||||
fail(`${label}: pathTemplate`);
|
||||
}
|
||||
assertValidator(contract.inputValidator, `${label}.input`);
|
||||
assertValidator(contract.outputValidator, `${label}.output`);
|
||||
assertValidator(contract.problemValidator, `${label}.problem`);
|
||||
if (typeof contract.projectRequest !== "function") {
|
||||
fail(`${label}: descriptor request projection is required`);
|
||||
}
|
||||
if (
|
||||
contract.retrySemantics !== "SAFE" &&
|
||||
contract.retrySemantics !== "IDEMPOTENT" &&
|
||||
contract.retrySemantics !== "KEYED" &&
|
||||
contract.retrySemantics !== "NEVER"
|
||||
) {
|
||||
fail(`${label}: retrySemantics`);
|
||||
}
|
||||
if (contract.requestBody !== "NONE" && contract.requestBody !== "JSON") {
|
||||
fail(`${label}: requestBody`);
|
||||
}
|
||||
if (
|
||||
contract.responseBody !== "REQUIRED_JSON" &&
|
||||
contract.responseBody !== "OPTIONAL_JSON" &&
|
||||
contract.responseBody !== "NONE"
|
||||
) {
|
||||
fail(`${label}: responseBody`);
|
||||
}
|
||||
assertStatuses(contract.acceptedStatuses, `${label}.acceptedStatuses`);
|
||||
if (contract.emptyBodyStatuses.length > 0) {
|
||||
assertStatuses(contract.emptyBodyStatuses, `${label}.emptyBodyStatuses`);
|
||||
const accepted = new Set(contract.acceptedStatuses);
|
||||
for (const status of contract.emptyBodyStatuses) {
|
||||
if (!accepted.has(status)) {
|
||||
fail(`${label}: empty-body status must be an accepted status`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const isRead = contract.method === "GET" || contract.method === "HEAD";
|
||||
if (isRead) {
|
||||
if (contract.commandEffect !== null || contract.commandRecovery !== null) {
|
||||
fail(`${label}: read operations carry no command descriptors`);
|
||||
}
|
||||
if (contract.requestBody !== "NONE") {
|
||||
fail(`${label}: read operations carry no request body`);
|
||||
}
|
||||
} else {
|
||||
if (!contract.commandEffect) {
|
||||
fail(`${label}: command operations require a command effect descriptor`);
|
||||
}
|
||||
if (contract.commandEffect.successEffect !== "APPLIED_CONFIRMED") {
|
||||
fail(`${label}: command success effect`);
|
||||
}
|
||||
if (typeof contract.commandEffect.classifyProblem !== "function") {
|
||||
fail(`${label}: command effect classifier is required`);
|
||||
}
|
||||
if (contract.retrySemantics === "KEYED" && !contract.commandRecovery) {
|
||||
fail(`${label}: KEYED commands require a recovery descriptor`);
|
||||
}
|
||||
}
|
||||
if (contract.commandRecovery) {
|
||||
const recovery = contract.commandRecovery;
|
||||
if (
|
||||
(recovery.mode !== "IDEMPOTENCY_REPLAY" &&
|
||||
recovery.mode !== "INSPECT_OPERATION") ||
|
||||
!recovery.operationIdentityField ||
|
||||
(recovery.mode === "INSPECT_OPERATION" && !recovery.inspectOperationId)
|
||||
) {
|
||||
fail(`${label}: command recovery descriptor`);
|
||||
}
|
||||
}
|
||||
assertExecutionPolicy(installed.frontend, label);
|
||||
if (
|
||||
installed.frontend.retryBudget > 0 &&
|
||||
contract.retrySemantics === "NEVER"
|
||||
) {
|
||||
fail(`${label}: retry budget contradicts NEVER retry semantics`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEventContract(
|
||||
event: InstalledEventContract<unknown, unknown>,
|
||||
featureId: string,
|
||||
): void {
|
||||
const label = `${featureId}/${String(event?.eventType)}`;
|
||||
if (
|
||||
typeof event?.eventType !== "string" ||
|
||||
event.eventType.length === 0 ||
|
||||
utf8Bytes(event.eventType) > 160 ||
|
||||
hasControlOrSpace(event.eventType)
|
||||
) {
|
||||
fail(`${label}: eventType`);
|
||||
}
|
||||
assertValidator(event.envelopeValidator, `${label}.envelope`);
|
||||
assertValidator(event.payloadValidator, `${label}.payload`);
|
||||
}
|
||||
|
||||
export type ComposedContractContributions = Readonly<{
|
||||
contributions: readonly InstalledContractContribution[];
|
||||
httpByOperationId: ReadonlyMap<
|
||||
string,
|
||||
InstalledHttpContract<unknown, unknown, unknown>
|
||||
>;
|
||||
eventByType: ReadonlyMap<string, InstalledEventContract<unknown, unknown>>;
|
||||
externalPackages: readonly InstalledContractPackageIdentity[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §4.8–§4.9. The only place installed contributions become a runtime registry.
|
||||
* Every bound is checked before composition; a violation stops the boot rather
|
||||
* than degrading into an assumed meaning.
|
||||
*/
|
||||
export function composeContractContributions(
|
||||
contributions: readonly InstalledContractContribution[],
|
||||
): ComposedContractContributions {
|
||||
if (!Array.isArray(contributions)) fail("contributions: array required");
|
||||
const httpByOperationId = new Map<
|
||||
string,
|
||||
InstalledHttpContract<unknown, unknown, unknown>
|
||||
>();
|
||||
const eventByType = new Map<
|
||||
string,
|
||||
InstalledEventContract<unknown, unknown>
|
||||
>();
|
||||
const packagesById = new Map<string, InstalledContractPackageIdentity>();
|
||||
const contributionIds = new Set<string>();
|
||||
|
||||
for (const contribution of contributions) {
|
||||
if (!contribution || typeof contribution !== "object") {
|
||||
fail("contribution: object required");
|
||||
}
|
||||
const contributionId = contribution.contributionId;
|
||||
if (
|
||||
typeof contributionId !== "string" ||
|
||||
!CONTRIBUTION_ID.test(contributionId)
|
||||
) {
|
||||
fail(`contributionId: ${String(contributionId)}`);
|
||||
}
|
||||
if (contributionIds.has(contributionId)) {
|
||||
fail(`duplicate contributionId: ${contributionId}`);
|
||||
}
|
||||
contributionIds.add(contributionId);
|
||||
const featureId = contribution?.featureId;
|
||||
if (typeof featureId !== "string" || !FEATURE_ID.test(featureId)) {
|
||||
fail(`featureId: ${String(featureId)}`);
|
||||
}
|
||||
const source = contribution.source;
|
||||
if (!source || typeof source !== "object" || !("kind" in source)) {
|
||||
fail(`${featureId}: source`);
|
||||
}
|
||||
if (!Array.isArray(contribution.http) || !Array.isArray(contribution.events)) {
|
||||
fail(`${featureId}: contribution arrays`);
|
||||
}
|
||||
if (source.kind === "EXTERNAL_PACKAGE") {
|
||||
assertPackageIdentity(source.package, featureId);
|
||||
const existing = packagesById.get(source.package.packageId);
|
||||
if (
|
||||
existing &&
|
||||
(existing.version !== source.package.version ||
|
||||
existing.digest !== source.package.digest ||
|
||||
existing.sourceRevision !== source.package.sourceRevision)
|
||||
) {
|
||||
fail(
|
||||
`${featureId}: package ${source.package.packageId} has conflicting identities`,
|
||||
);
|
||||
}
|
||||
packagesById.set(source.package.packageId, source.package);
|
||||
} else if (source.kind === "TEMPLATE_FIXTURE") {
|
||||
if (source.fixtureId !== "REFERENCE_FEATURE_V1" || source.revision !== 1) {
|
||||
fail(`${featureId}: template fixture identity`);
|
||||
}
|
||||
if (contribution.events.length !== 0) {
|
||||
fail(`${featureId}: template fixture must not contribute events`);
|
||||
}
|
||||
} else {
|
||||
fail(`${featureId}: unknown contribution source kind`);
|
||||
}
|
||||
|
||||
for (const installed of contribution.http) {
|
||||
assertHttpContract(installed, featureId);
|
||||
const operationId = installed.contract.operationId;
|
||||
const previous = httpByOperationId.get(operationId);
|
||||
if (previous) fail(`duplicate operation: ${operationId}`);
|
||||
httpByOperationId.set(operationId, installed);
|
||||
}
|
||||
|
||||
for (const event of contribution.events) {
|
||||
assertEventContract(event, featureId);
|
||||
if (eventByType.has(event.eventType)) {
|
||||
fail(`duplicate event type: ${event.eventType}`);
|
||||
}
|
||||
eventByType.set(event.eventType, event);
|
||||
}
|
||||
}
|
||||
|
||||
const externalPackages = [...packagesById.values()].map((identity) =>
|
||||
Object.freeze({ ...identity }),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
contributions: Object.freeze([...contributions]),
|
||||
httpByOperationId,
|
||||
eventByType,
|
||||
externalPackages: Object.freeze(externalPackages),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const MUTATION_INTENT_BOUNDS = Object.freeze({
|
||||
intentIdMaxBytes: 256,
|
||||
operationIdMaxBytes: 256,
|
||||
canonicalInputIdentityMaxBytes: 16_384,
|
||||
idempotencyKeyMaxBytes: 256,
|
||||
} as const);
|
||||
|
||||
export type MutationIntent = Readonly<{
|
||||
intentId: string;
|
||||
operationId: string;
|
||||
canonicalInputIdentity: string;
|
||||
idempotencyKey?: string;
|
||||
createdAtMonotonicMs: number;
|
||||
}>;
|
||||
|
||||
const UTF8 = new TextEncoder();
|
||||
|
||||
function validBoundedString(value: unknown, maxBytes: number): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.trim().length > 0 &&
|
||||
UTF8.encode(value).byteLength <= maxBytes
|
||||
);
|
||||
}
|
||||
|
||||
export function defineMutationIntent(intent: MutationIntent): MutationIntent {
|
||||
if (
|
||||
!validBoundedString(
|
||||
intent.intentId,
|
||||
MUTATION_INTENT_BOUNDS.intentIdMaxBytes,
|
||||
) ||
|
||||
!validBoundedString(
|
||||
intent.operationId,
|
||||
MUTATION_INTENT_BOUNDS.operationIdMaxBytes,
|
||||
) ||
|
||||
!validBoundedString(
|
||||
intent.canonicalInputIdentity,
|
||||
MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes,
|
||||
) ||
|
||||
(intent.idempotencyKey !== undefined &&
|
||||
!validBoundedString(
|
||||
intent.idempotencyKey,
|
||||
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes,
|
||||
)) ||
|
||||
!Number.isFinite(intent.createdAtMonotonicMs) ||
|
||||
intent.createdAtMonotonicMs < 0
|
||||
) {
|
||||
throw new TypeError("Mutation intent is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
intentId: intent.intentId,
|
||||
operationId: intent.operationId,
|
||||
canonicalInputIdentity: intent.canonicalInputIdentity,
|
||||
...(intent.idempotencyKey === undefined
|
||||
? {}
|
||||
: { idempotencyKey: intent.idempotencyKey }),
|
||||
createdAtMonotonicMs: intent.createdAtMonotonicMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* §19. Offline Command and Background Sync contract.
|
||||
*
|
||||
* The product capability is `NOT_SELECTED` (§19.1). An operation only becomes
|
||||
* queueable when the external package contribution provides `KEYED` retry
|
||||
* semantics plus a non-null recovery descriptor (§19.3); the frontend never
|
||||
* defines server idempotency or an inspect protocol of its own.
|
||||
*/
|
||||
|
||||
export const OFFLINE_COMMAND_BOUNDS = Object.freeze({
|
||||
operations: 64,
|
||||
defaultRequestBytes: 262_144,
|
||||
hardRequestBytes: 1_048_576,
|
||||
records: 1_000,
|
||||
datasetBytes: 50 * 1024 * 1024,
|
||||
senderLeaseMs: 30_000,
|
||||
leaseRenewMs: 10_000,
|
||||
batchCount: 10,
|
||||
batchWindowMs: 30_000,
|
||||
parallelSend: 1,
|
||||
retryBaseMs: 1_000,
|
||||
retryMaxMs: 300_000,
|
||||
attemptCap: 10,
|
||||
ordinaryRetentionMs: 7 * 24 * 60 * 60 * 1_000,
|
||||
conflictRetentionMs: 30 * 24 * 60 * 60 * 1_000,
|
||||
ackedSummaryRetentionMs: 24 * 60 * 60 * 1_000,
|
||||
syncReregisterMinimumMs: 60_000,
|
||||
});
|
||||
|
||||
/** §19.18. Background Sync is a wake-up hint only; it never sends a command. */
|
||||
export const OFFLINE_SYNC_TAG = "ca-outbox-v1" as const;
|
||||
|
||||
export interface InstalledOfflineOperation {
|
||||
readonly operationId: string;
|
||||
readonly contractPackageId: string;
|
||||
readonly maximumRequestBytes: number;
|
||||
readonly retentionClass: "STANDARD_7D";
|
||||
}
|
||||
|
||||
export interface InstalledOfflineCommandContribution {
|
||||
readonly datasetId: "OFFLINE_COMMANDS_V1";
|
||||
readonly operations: readonly InstalledOfflineOperation[];
|
||||
}
|
||||
|
||||
export type OfflineCommandState =
|
||||
| "PENDING"
|
||||
| "LEASED"
|
||||
| "FOREGROUND_REQUIRED"
|
||||
| "SENDING"
|
||||
| "RETRY_WAIT"
|
||||
| "ACKED"
|
||||
| "CONFLICT"
|
||||
| "EFFECT_UNKNOWN"
|
||||
| "EXPIRED";
|
||||
|
||||
export interface OfflineCommandRecordV1 {
|
||||
readonly recordVersion: 1;
|
||||
readonly commandId: string;
|
||||
readonly operationId: string;
|
||||
readonly contractPackageId: string;
|
||||
readonly contractPackageVersion: string;
|
||||
readonly contractPackageDigest: `sha256:${string}`;
|
||||
readonly scopePartition: string;
|
||||
readonly requestDigest: `sha256:${string}`;
|
||||
readonly requestPayload: Uint8Array;
|
||||
readonly idempotencyKey: string;
|
||||
readonly state: OfflineCommandState;
|
||||
readonly attempt: number;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly nextAttemptAt?: string;
|
||||
readonly leaseOwner?: string;
|
||||
readonly leaseExpiresAt?: string;
|
||||
readonly terminalCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* §19.10. Anything not listed is corruption. In particular an expired
|
||||
* `SENDING` record is never reset to `PENDING`: it becomes `EFFECT_UNKNOWN`.
|
||||
*/
|
||||
const ALLOWED_TRANSITIONS = Object.freeze({
|
||||
PENDING: Object.freeze(["LEASED", "FOREGROUND_REQUIRED", "EXPIRED"]),
|
||||
LEASED: Object.freeze(["SENDING", "PENDING"]),
|
||||
SENDING: Object.freeze([
|
||||
"ACKED",
|
||||
"RETRY_WAIT",
|
||||
"CONFLICT",
|
||||
"EFFECT_UNKNOWN",
|
||||
]),
|
||||
RETRY_WAIT: Object.freeze(["LEASED", "FOREGROUND_REQUIRED", "EXPIRED"]),
|
||||
FOREGROUND_REQUIRED: Object.freeze(["LEASED", "EXPIRED", "PENDING"]),
|
||||
ACKED: Object.freeze([]),
|
||||
CONFLICT: Object.freeze([]),
|
||||
EFFECT_UNKNOWN: Object.freeze(["ACKED", "PENDING"]),
|
||||
EXPIRED: Object.freeze([]),
|
||||
} satisfies Readonly<Record<OfflineCommandState, readonly OfflineCommandState[]>>);
|
||||
|
||||
export function isAllowedOfflineTransition(
|
||||
from: OfflineCommandState,
|
||||
to: OfflineCommandState,
|
||||
): boolean {
|
||||
const allowed: readonly OfflineCommandState[] = ALLOWED_TRANSITIONS[from];
|
||||
return allowed.includes(to);
|
||||
}
|
||||
|
||||
/** §19.21. Nothing sensitive reaches the UI: no payload, key, digest or partition. */
|
||||
export interface OfflineCommandSummary {
|
||||
readonly commandId: string;
|
||||
readonly operationLabelKey: string;
|
||||
readonly state: OfflineCommandState;
|
||||
readonly createdAt: string;
|
||||
readonly nextAction:
|
||||
| "WAIT"
|
||||
| "OPEN_APP"
|
||||
| "CHECK_STATUS"
|
||||
| "RESOLVE_CONFLICT"
|
||||
| "CONTACT_SUPPORT"
|
||||
| "DISMISS";
|
||||
}
|
||||
|
||||
export function validateOfflineCommandContribution(
|
||||
contribution: InstalledOfflineCommandContribution,
|
||||
): InstalledOfflineCommandContribution {
|
||||
if (contribution.datasetId !== "OFFLINE_COMMANDS_V1") {
|
||||
throw new TypeError("Offline command dataset identity is invalid.");
|
||||
}
|
||||
if (
|
||||
contribution.operations.length === 0 ||
|
||||
contribution.operations.length > OFFLINE_COMMAND_BOUNDS.operations
|
||||
) {
|
||||
throw new TypeError("Offline command operation count is out of range.");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const operation of contribution.operations) {
|
||||
if (!operation.operationId || seen.has(operation.operationId)) {
|
||||
throw new TypeError("Duplicate offline command operation.");
|
||||
}
|
||||
seen.add(operation.operationId);
|
||||
if (
|
||||
!Number.isSafeInteger(operation.maximumRequestBytes) ||
|
||||
operation.maximumRequestBytes < 1 ||
|
||||
operation.maximumRequestBytes > OFFLINE_COMMAND_BOUNDS.hardRequestBytes ||
|
||||
operation.retentionClass !== "STANDARD_7D"
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Offline command operation bounds invalid: ${operation.operationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
datasetId: contribution.datasetId,
|
||||
operations: Object.freeze([...contribution.operations]),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { isCacheInvalidationTopic } from "./cache-invalidation.ts";
|
||||
import {
|
||||
defineQueryNamespaceIdentity,
|
||||
queryNamespaceIdentityKey,
|
||||
type QueryNamespaceIdentity,
|
||||
} from "./query-keys.ts";
|
||||
|
||||
declare const queryInvalidationTopicBrand: unique symbol;
|
||||
|
||||
/**
|
||||
* Opaque registry-issued invalidation identity. The brand prevents feature
|
||||
* code from accidentally passing a concrete query-key string to the mutation
|
||||
* bridge.
|
||||
*/
|
||||
export type QueryInvalidationTopic = string &
|
||||
Readonly<{ [queryInvalidationTopicBrand]: true }>;
|
||||
|
||||
export function defineQueryInvalidationTopic(
|
||||
value: string,
|
||||
): QueryInvalidationTopic {
|
||||
if (!isCacheInvalidationTopic(value)) {
|
||||
throw new TypeError("Query invalidation topic is invalid.");
|
||||
}
|
||||
return value as QueryInvalidationTopic;
|
||||
}
|
||||
|
||||
/**
|
||||
* §12.2. Many-to-many topic/namespace registry.
|
||||
*
|
||||
* Topics stay opaque on the wire; the registry is what turns one received topic
|
||||
* into the local namespaces that must revalidate. Bounds are checked at startup
|
||||
* so a fan-out explosion cannot be introduced at runtime.
|
||||
*/
|
||||
export const INVALIDATION_REGISTRY_BOUNDS = Object.freeze({
|
||||
maxTopics: 256,
|
||||
maxNamespaces: 256,
|
||||
maxEdges: 1_024,
|
||||
maxTopicFanOut: 64,
|
||||
maxNamespaceFanIn: 64,
|
||||
maxIdBytes: 80,
|
||||
});
|
||||
|
||||
export type InvalidationRegistryEdge = Readonly<{
|
||||
topicId: string;
|
||||
namespace: QueryNamespaceIdentity;
|
||||
}>;
|
||||
|
||||
export interface InvalidationRegistry {
|
||||
readonly topics: readonly string[];
|
||||
readonly namespaces: readonly QueryNamespaceIdentity[];
|
||||
readonly edges: readonly InvalidationRegistryEdge[];
|
||||
}
|
||||
|
||||
export type InvalidationRegistryIndex = Readonly<{
|
||||
namespacesForTopic: ReadonlyMap<
|
||||
string,
|
||||
readonly QueryNamespaceIdentity[]
|
||||
>;
|
||||
topicsForNamespace: ReadonlyMap<string, readonly string[]>;
|
||||
}>;
|
||||
|
||||
export type InvalidationTopicVersionDefinition = Readonly<{
|
||||
topicId: string;
|
||||
topicVersion: number;
|
||||
}>;
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (codePoint <= 0x1f || codePoint === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects duplicates, orphan topics and orphan namespaces at startup. An edge
|
||||
* that points at an unregistered endpoint is a composition defect, not a
|
||||
* runtime condition to be tolerated.
|
||||
*/
|
||||
export function indexInvalidationRegistry(
|
||||
registry: InvalidationRegistry,
|
||||
): InvalidationRegistryIndex {
|
||||
const bounds = INVALIDATION_REGISTRY_BOUNDS;
|
||||
const encoder = new TextEncoder();
|
||||
const assertId = (value: string, label: string) => {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
hasControlCharacter(value) ||
|
||||
encoder.encode(value).byteLength > bounds.maxIdBytes
|
||||
) {
|
||||
throw new TypeError(`Invalidation registry ${label} is invalid.`);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
registry.topics.length > bounds.maxTopics ||
|
||||
registry.namespaces.length > bounds.maxNamespaces ||
|
||||
registry.edges.length > bounds.maxEdges
|
||||
) {
|
||||
throw new TypeError("Invalidation registry exceeds its bounds.");
|
||||
}
|
||||
|
||||
const topics = new Set<string>();
|
||||
for (const topic of registry.topics) {
|
||||
assertId(topic, "topic");
|
||||
if (topics.has(topic)) {
|
||||
throw new TypeError(`Duplicate invalidation topic: ${topic}`);
|
||||
}
|
||||
topics.add(topic);
|
||||
}
|
||||
const namespaces = new Map<string, QueryNamespaceIdentity>();
|
||||
for (const namespace of registry.namespaces) {
|
||||
let namespaceKey: string;
|
||||
let namespaceSnapshot: QueryNamespaceIdentity;
|
||||
try {
|
||||
namespaceSnapshot = defineQueryNamespaceIdentity(
|
||||
namespace.namespaceId,
|
||||
namespace.namespaceVersion,
|
||||
);
|
||||
namespaceKey = queryNamespaceIdentityKey(namespaceSnapshot);
|
||||
} catch (error) {
|
||||
throw new TypeError("Invalidation registry namespace is invalid.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (namespaces.has(namespaceKey)) {
|
||||
throw new TypeError(`Duplicate invalidation namespace: ${namespaceKey}`);
|
||||
}
|
||||
namespaces.set(namespaceKey, namespaceSnapshot);
|
||||
}
|
||||
|
||||
const namespacesForTopic = new Map<string, QueryNamespaceIdentity[]>();
|
||||
const topicsForNamespace = new Map<string, string[]>();
|
||||
const seenEdges = new Map<string, Set<string>>();
|
||||
for (const edge of registry.edges) {
|
||||
let namespaceKey: string;
|
||||
try {
|
||||
namespaceKey = queryNamespaceIdentityKey(edge.namespace);
|
||||
} catch (error) {
|
||||
throw new TypeError("Invalidation registry namespace is invalid.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const registeredNamespace = namespaces.get(namespaceKey);
|
||||
if (!topics.has(edge.topicId) || !registeredNamespace) {
|
||||
throw new TypeError("Invalidation edge references an unknown endpoint.");
|
||||
}
|
||||
const seenNamespaces = seenEdges.get(edge.topicId) ?? new Set<string>();
|
||||
if (seenNamespaces.has(namespaceKey)) {
|
||||
throw new TypeError("Duplicate invalidation edge.");
|
||||
}
|
||||
seenNamespaces.add(namespaceKey);
|
||||
seenEdges.set(edge.topicId, seenNamespaces);
|
||||
|
||||
const fanOut = namespacesForTopic.get(edge.topicId) ?? [];
|
||||
fanOut.push(registeredNamespace);
|
||||
if (fanOut.length > bounds.maxTopicFanOut) {
|
||||
throw new TypeError(`Invalidation topic fan-out exceeded: ${edge.topicId}`);
|
||||
}
|
||||
namespacesForTopic.set(edge.topicId, fanOut);
|
||||
|
||||
const fanIn = topicsForNamespace.get(namespaceKey) ?? [];
|
||||
fanIn.push(edge.topicId);
|
||||
if (fanIn.length > bounds.maxNamespaceFanIn) {
|
||||
throw new TypeError(
|
||||
`Invalidation namespace fan-in exceeded: ${namespaceKey}`,
|
||||
);
|
||||
}
|
||||
topicsForNamespace.set(namespaceKey, fanIn);
|
||||
}
|
||||
|
||||
for (const topic of topics) {
|
||||
if (!namespacesForTopic.has(topic)) {
|
||||
throw new TypeError(`Orphan invalidation topic: ${topic}`);
|
||||
}
|
||||
}
|
||||
for (const namespaceKey of namespaces.keys()) {
|
||||
if (!topicsForNamespace.has(namespaceKey)) {
|
||||
throw new TypeError(`Orphan invalidation namespace: ${namespaceKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
namespacesForTopic: new Map(
|
||||
[...namespacesForTopic].map(([key, value]) => [
|
||||
key,
|
||||
Object.freeze([...value]) as readonly QueryNamespaceIdentity[],
|
||||
]),
|
||||
),
|
||||
topicsForNamespace: new Map(
|
||||
[...topicsForNamespace].map(([key, value]) => [
|
||||
key,
|
||||
Object.freeze([...value]) as readonly string[],
|
||||
]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Projects the graph's wire-only topic versions and rejects composition drift. */
|
||||
export function indexInvalidationTopicVersions(
|
||||
registry: Pick<InvalidationRegistry, "topics">,
|
||||
definitions: readonly InvalidationTopicVersionDefinition[],
|
||||
): ReadonlyMap<string, number> {
|
||||
if (
|
||||
registry.topics.length > INVALIDATION_REGISTRY_BOUNDS.maxTopics ||
|
||||
definitions.length > INVALIDATION_REGISTRY_BOUNDS.maxTopics
|
||||
) {
|
||||
throw new TypeError("Invalidation topic version registry exceeds its bounds.");
|
||||
}
|
||||
const registeredTopics = new Set(registry.topics);
|
||||
if (
|
||||
registeredTopics.size !== registry.topics.length ||
|
||||
definitions.length !== registeredTopics.size
|
||||
) {
|
||||
throw new TypeError("Invalidation topic version registry is inconsistent.");
|
||||
}
|
||||
|
||||
const versions = new Map<string, number>();
|
||||
for (const definition of definitions) {
|
||||
if (
|
||||
!isCacheInvalidationTopic(definition.topicId) ||
|
||||
!registeredTopics.has(definition.topicId) ||
|
||||
!Number.isSafeInteger(definition.topicVersion) ||
|
||||
definition.topicVersion < 1 ||
|
||||
versions.has(definition.topicId)
|
||||
) {
|
||||
throw new TypeError("Invalidation topic version registry is invalid.");
|
||||
}
|
||||
versions.set(definition.topicId, definition.topicVersion);
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
export type QueryMutationLease = Readonly<{
|
||||
/**
|
||||
* Releases one local mutation fence. Remote hints coalesced while the fence
|
||||
* was held are applied once after the final lease for each topic is released.
|
||||
*/
|
||||
release(): Promise<void>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Presentation-side facade for server-state invalidation.
|
||||
*
|
||||
* The caller knows only registry-issued topics. Query keys, BroadcastChannel
|
||||
* envelopes and browser transports stay inside the query infrastructure.
|
||||
*/
|
||||
export interface QueryInvalidationCoordinator {
|
||||
invalidate(topics: readonly QueryInvalidationTopic[]): Promise<void>;
|
||||
beginMutation(topics: readonly QueryInvalidationTopic[]): QueryMutationLease;
|
||||
/**
|
||||
* Local verified lifecycle only. A remote invalidation hint is never allowed
|
||||
* to clear the complete cache.
|
||||
*/
|
||||
resetLocal(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
export const QUERY_KEY_SCHEMA_VERSION = 2 as const;
|
||||
export const QUERY_NAMESPACE_ID_MAX_BYTES = 80 as const;
|
||||
|
||||
export type QueryNamespaceIdentity = Readonly<{
|
||||
namespaceId: string;
|
||||
namespaceVersion: number;
|
||||
}>;
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (
|
||||
codePoint <= 0x1f ||
|
||||
(codePoint >= 0x7f && codePoint <= 0x9f)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function assertQueryNamespaceIdentity(
|
||||
namespace: QueryNamespaceIdentity,
|
||||
): void {
|
||||
if (
|
||||
!namespace ||
|
||||
typeof namespace !== "object" ||
|
||||
typeof namespace.namespaceId !== "string" ||
|
||||
namespace.namespaceId.length === 0 ||
|
||||
hasControlCharacter(namespace.namespaceId) ||
|
||||
new TextEncoder().encode(namespace.namespaceId).byteLength >
|
||||
QUERY_NAMESPACE_ID_MAX_BYTES ||
|
||||
!Number.isSafeInteger(namespace.namespaceVersion) ||
|
||||
namespace.namespaceVersion < 1
|
||||
) {
|
||||
throw new TypeError("Query namespace identity is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
export function defineQueryNamespaceIdentity(
|
||||
namespaceId: string,
|
||||
namespaceVersion: number,
|
||||
): QueryNamespaceIdentity {
|
||||
const namespace = { namespaceId, namespaceVersion };
|
||||
assertQueryNamespaceIdentity(namespace);
|
||||
return Object.freeze(namespace);
|
||||
}
|
||||
|
||||
export function queryNamespaceIdentityKey(
|
||||
namespace: QueryNamespaceIdentity,
|
||||
): string {
|
||||
assertQueryNamespaceIdentity(namespace);
|
||||
return JSON.stringify([namespace.namespaceId, namespace.namespaceVersion]);
|
||||
}
|
||||
|
||||
export function createQueryInvalidationPrefix(
|
||||
namespace: QueryNamespaceIdentity,
|
||||
) {
|
||||
assertQueryNamespaceIdentity(namespace);
|
||||
return Object.freeze([
|
||||
"query",
|
||||
QUERY_KEY_SCHEMA_VERSION,
|
||||
namespace.namespaceId,
|
||||
namespace.namespaceVersion,
|
||||
] as const);
|
||||
}
|
||||
|
||||
export function createBoundQueryKey(
|
||||
namespace: QueryNamespaceIdentity,
|
||||
scopeFingerprint: string,
|
||||
definitionVersion: number,
|
||||
identityToken: string,
|
||||
) {
|
||||
if (!Number.isSafeInteger(definitionVersion) || definitionVersion < 1) {
|
||||
throw new TypeError("Query definition version is invalid.");
|
||||
}
|
||||
return Object.freeze([
|
||||
...createQueryInvalidationPrefix(namespace),
|
||||
scopeFingerprint,
|
||||
definitionVersion,
|
||||
identityToken,
|
||||
] as const);
|
||||
}
|
||||
|
||||
export type CanonicalValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| readonly CanonicalValue[]
|
||||
| Readonly<{ [key: string]: CanonicalValue }>;
|
||||
|
||||
const DEFAULT_LIMITS = Object.freeze({
|
||||
maxDepth: 12,
|
||||
maxNodes: 512,
|
||||
maxStringBytes: 2_048,
|
||||
maxEncodedBytes: 16_384,
|
||||
});
|
||||
|
||||
export function canonicalize(value: unknown): CanonicalValue {
|
||||
const seen = new WeakSet<object>();
|
||||
let nodes = 0;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function visit(candidate: unknown, depth: number): CanonicalValue {
|
||||
nodes += 1;
|
||||
if (nodes > DEFAULT_LIMITS.maxNodes || depth > DEFAULT_LIMITS.maxDepth) {
|
||||
throw new TypeError("Query identity exceeds its structural budget.");
|
||||
}
|
||||
if (
|
||||
candidate === null ||
|
||||
typeof candidate === "boolean" ||
|
||||
(typeof candidate === "number" &&
|
||||
Number.isFinite(candidate) &&
|
||||
!Object.is(candidate, -0))
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
if (typeof candidate === "string") {
|
||||
if (encoder.encode(candidate).byteLength > DEFAULT_LIMITS.maxStringBytes) {
|
||||
throw new TypeError("Query identity string exceeds its byte budget.");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
throw new TypeError("Query identity contains a non-canonical value.");
|
||||
}
|
||||
if (seen.has(candidate)) {
|
||||
throw new TypeError("Query identity contains a cycle or shared reference.");
|
||||
}
|
||||
seen.add(candidate);
|
||||
if (Array.isArray(candidate)) {
|
||||
for (let index = 0; index < candidate.length; index += 1) {
|
||||
if (!Object.hasOwn(candidate, index)) {
|
||||
throw new TypeError("Query identity contains a sparse array.");
|
||||
}
|
||||
}
|
||||
return Object.freeze(candidate.map((item) => visit(item, depth + 1)));
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(candidate);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("Query identity requires plain objects.");
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(candidate);
|
||||
const output: Record<string, CanonicalValue> = Object.create(null);
|
||||
for (const key of Object.keys(descriptors).sort()) {
|
||||
if (key === "__proto__" || key === "prototype" || key === "constructor") {
|
||||
throw new TypeError("Query identity contains a forbidden key.");
|
||||
}
|
||||
const descriptor = descriptors[key];
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError("Query identity contains an accessor.");
|
||||
}
|
||||
output[key] = visit(descriptor.value, depth + 1);
|
||||
}
|
||||
return Object.freeze(output);
|
||||
}
|
||||
|
||||
const result = visit(value, 0);
|
||||
if (encoder.encode(JSON.stringify(result)).byteLength > DEFAULT_LIMITS.maxEncodedBytes) {
|
||||
throw new TypeError("Query identity exceeds its encoded byte budget.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type RuntimeIdentityBinding = Readonly<{
|
||||
token: string;
|
||||
acquire(): void;
|
||||
release(): void;
|
||||
}>;
|
||||
|
||||
export type RuntimeIdentityRegistry = Readonly<{
|
||||
intern(value: unknown): RuntimeIdentityBinding;
|
||||
close(): void;
|
||||
inspect(): Readonly<{
|
||||
entries: number;
|
||||
canonicalBytes: number;
|
||||
activeLeases: number;
|
||||
closed: boolean;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
type IdentityRow = {
|
||||
canonical: string;
|
||||
canonicalBytes: number;
|
||||
token: string;
|
||||
refCount: number;
|
||||
touched: number;
|
||||
};
|
||||
|
||||
export function createRuntimeIdentityRegistry(
|
||||
options: Readonly<{
|
||||
maxEntries?: number;
|
||||
maxCanonicalBytes?: number;
|
||||
tokenFactory?: () => string;
|
||||
}> = {},
|
||||
): RuntimeIdentityRegistry {
|
||||
const maxEntries = options.maxEntries ?? 4_096;
|
||||
const maxCanonicalBytes = options.maxCanonicalBytes ?? 4 * 1024 * 1024;
|
||||
const tokenFactory =
|
||||
options.tokenFactory ??
|
||||
(() => {
|
||||
if (
|
||||
typeof crypto === "undefined" ||
|
||||
typeof crypto.randomUUID !== "function"
|
||||
) {
|
||||
throw new TypeError("Secure runtime identity generation is unavailable.");
|
||||
}
|
||||
return crypto.randomUUID();
|
||||
});
|
||||
const byCanonical = new Map<string, IdentityRow>();
|
||||
const byToken = new Map<string, IdentityRow>();
|
||||
let totalCanonicalBytes = 0;
|
||||
let sequence = 0;
|
||||
let closed = false;
|
||||
|
||||
function evictAvailable(requiredBytes: number): void {
|
||||
const candidates = [...byCanonical.values()]
|
||||
.filter((row) => row.refCount === 0)
|
||||
.sort((left, right) => left.touched - right.touched);
|
||||
for (const row of candidates) {
|
||||
if (
|
||||
byCanonical.size < maxEntries &&
|
||||
totalCanonicalBytes + requiredBytes <= maxCanonicalBytes
|
||||
) {
|
||||
return;
|
||||
}
|
||||
byCanonical.delete(row.canonical);
|
||||
byToken.delete(row.token);
|
||||
totalCanonicalBytes -= row.canonicalBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
intern(value): RuntimeIdentityBinding {
|
||||
if (closed) throw new TypeError("Runtime identity registry is closed.");
|
||||
const canonical = JSON.stringify(canonicalize(value));
|
||||
const canonicalBytes = new TextEncoder().encode(canonical).byteLength;
|
||||
let row = byCanonical.get(canonical);
|
||||
if (!row) {
|
||||
evictAvailable(canonicalBytes);
|
||||
if (
|
||||
byCanonical.size >= maxEntries ||
|
||||
totalCanonicalBytes + canonicalBytes > maxCanonicalBytes
|
||||
) {
|
||||
throw new TypeError("Runtime identity capacity exceeded.");
|
||||
}
|
||||
let token = "";
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const candidate = tokenFactory();
|
||||
if (
|
||||
/^[A-Za-z0-9._:-]{16,128}$/.test(candidate) &&
|
||||
!byToken.has(candidate)
|
||||
) {
|
||||
token = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!token) {
|
||||
throw new TypeError("Runtime identity token collision.");
|
||||
}
|
||||
row = {
|
||||
canonical,
|
||||
canonicalBytes,
|
||||
token,
|
||||
refCount: 0,
|
||||
touched: sequence++,
|
||||
};
|
||||
byCanonical.set(canonical, row);
|
||||
byToken.set(token, row);
|
||||
totalCanonicalBytes += canonicalBytes;
|
||||
}
|
||||
row.touched = sequence++;
|
||||
let leaseCount = 0;
|
||||
return Object.freeze({
|
||||
token: row.token,
|
||||
acquire() {
|
||||
if (closed) return;
|
||||
leaseCount += 1;
|
||||
row.refCount += 1;
|
||||
row.touched = sequence++;
|
||||
},
|
||||
release() {
|
||||
if (leaseCount === 0) return;
|
||||
leaseCount -= 1;
|
||||
row.refCount = Math.max(0, row.refCount - 1);
|
||||
row.touched = sequence++;
|
||||
},
|
||||
});
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
byCanonical.clear();
|
||||
byToken.clear();
|
||||
totalCanonicalBytes = 0;
|
||||
},
|
||||
inspect() {
|
||||
return Object.freeze({
|
||||
entries: byCanonical.size,
|
||||
canonicalBytes: totalCanonicalBytes,
|
||||
activeLeases: [...byCanonical.values()].reduce(
|
||||
(total, row) => total + row.refCount,
|
||||
0,
|
||||
),
|
||||
closed,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const defaultIdentityRegistry = createRuntimeIdentityRegistry();
|
||||
|
||||
export function runtimeIdentityToken(value: unknown): string {
|
||||
return defaultIdentityRegistry.intern(value).token;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
REALTIME_EVENT_PROTOCOL,
|
||||
type EventTypeId,
|
||||
type StreamRegistrationId,
|
||||
} from "./realtime-streams.ts";
|
||||
|
||||
export const REALTIME_EVENT_FIELD_LIMITS = Object.freeze({
|
||||
maxOpaqueIdentifierLength: 128,
|
||||
maxScopeBindingLength: 256,
|
||||
maxResumeCursorLength: 1_024,
|
||||
maxSequenceDigits: 20,
|
||||
maxTimestampFractionDigits: 9,
|
||||
});
|
||||
|
||||
export const REALTIME_MAX_SEQUENCE = "18446744073709551615";
|
||||
|
||||
export type RealtimeEventBase<Payload> = Readonly<{
|
||||
protocol: typeof REALTIME_EVENT_PROTOCOL;
|
||||
streamId: StreamRegistrationId;
|
||||
streamEpoch: string;
|
||||
eventType: EventTypeId;
|
||||
eventId: string;
|
||||
sequence: string;
|
||||
occurredAt: string;
|
||||
scopeBinding: string;
|
||||
payload: Payload;
|
||||
}>;
|
||||
|
||||
export type RealtimeEventEnvelope<Payload = unknown> = Readonly<
|
||||
RealtimeEventBase<Payload> &
|
||||
(
|
||||
| Readonly<{
|
||||
recoveryMode: "CURSOR";
|
||||
resumeCursor: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
recoveryMode: "SNAPSHOT_ONLY" | "SESSION_REBUILD";
|
||||
resumeCursor: null;
|
||||
}>
|
||||
)
|
||||
>;
|
||||
|
||||
export type SnapshotCheckpoint = Readonly<
|
||||
{
|
||||
streamEpoch: string;
|
||||
lastAppliedSequence: string;
|
||||
snapshotRevision: string;
|
||||
} & (
|
||||
| Readonly<{
|
||||
recoveryMode: "CURSOR";
|
||||
resumeCursor: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
recoveryMode: "SNAPSHOT_ONLY";
|
||||
resumeCursor: null;
|
||||
}>
|
||||
)
|
||||
>;
|
||||
|
||||
export type RealtimeResumeState = Readonly<
|
||||
{
|
||||
streamEpoch: string;
|
||||
lastAppliedSequence: string;
|
||||
} & (
|
||||
| Readonly<{
|
||||
recoveryMode: "CURSOR";
|
||||
resumeCursor: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
recoveryMode: "SNAPSHOT_ONLY" | "SESSION_REBUILD";
|
||||
resumeCursor: null;
|
||||
}>
|
||||
)
|
||||
>;
|
||||
|
||||
const OPAQUE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:~+/=-]*$/u;
|
||||
const HEADER_SAFE_CURSOR = /^[\x21-\x7e]+$/u;
|
||||
const CANONICAL_SEQUENCE = /^(?:0|[1-9][0-9]{0,19})$/u;
|
||||
const RFC_3339 =
|
||||
/^([0-9]{4})-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(?:\.([0-9]{1,9}))?(Z|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))$/u;
|
||||
|
||||
export function isRealtimeOpaqueIdentifier(
|
||||
value: unknown,
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length >= 1 &&
|
||||
value.length <=
|
||||
REALTIME_EVENT_FIELD_LIMITS.maxOpaqueIdentifierLength &&
|
||||
OPAQUE_IDENTIFIER.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRealtimeScopeBinding(
|
||||
value: unknown,
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length >= 1 &&
|
||||
value.length <= REALTIME_EVENT_FIELD_LIMITS.maxScopeBindingLength &&
|
||||
OPAQUE_IDENTIFIER.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRealtimeResumeCursor(
|
||||
value: unknown,
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length >= 1 &&
|
||||
value.length <=
|
||||
REALTIME_EVENT_FIELD_LIMITS.maxResumeCursorLength &&
|
||||
HEADER_SAFE_CURSOR.test(value) &&
|
||||
!value.includes("\0") &&
|
||||
!value.includes("\r") &&
|
||||
!value.includes("\n")
|
||||
);
|
||||
}
|
||||
|
||||
export function isCanonicalRealtimeSequence(
|
||||
value: unknown,
|
||||
): value is string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > REALTIME_EVENT_FIELD_LIMITS.maxSequenceDigits ||
|
||||
!CANONICAL_SEQUENCE.test(value)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return BigInt(value) <= BigInt(REALTIME_MAX_SEQUENCE);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function compareRealtimeSequences(
|
||||
left: string,
|
||||
right: string,
|
||||
): -1 | 0 | 1 {
|
||||
if (
|
||||
!isCanonicalRealtimeSequence(left) ||
|
||||
!isCanonicalRealtimeSequence(right)
|
||||
) {
|
||||
throw new TypeError("Realtime sequence is invalid.");
|
||||
}
|
||||
const leftValue = BigInt(left);
|
||||
const rightValue = BigInt(right);
|
||||
return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0;
|
||||
}
|
||||
|
||||
export function nextRealtimeSequence(value: string): string | null {
|
||||
if (!isCanonicalRealtimeSequence(value)) {
|
||||
throw new TypeError("Realtime sequence is invalid.");
|
||||
}
|
||||
const next = BigInt(value) + 1n;
|
||||
return next > BigInt(REALTIME_MAX_SEQUENCE) ? null : next.toString(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounded RFC 3339 profile: uppercase T/Z, a required offset, real calendar
|
||||
* dates, seconds 00-59 and at most nanosecond fractional precision.
|
||||
*/
|
||||
export function isStrictRealtimeTimestamp(
|
||||
value: unknown,
|
||||
): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const match = RFC_3339.exec(value);
|
||||
if (!match) return false;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (year < 1 || day > daysInMonth(year, month)) return false;
|
||||
return Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
if (month === 2) {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
|
||||
? 29
|
||||
: 28;
|
||||
}
|
||||
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import type { ApiOperation } from "./api-operations.ts";
|
||||
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
|
||||
import type { RuntimeSchemaCodec } from "./schema-registry.ts";
|
||||
|
||||
declare const streamRegistrationIdBrand: unique symbol;
|
||||
declare const eventTypeIdBrand: unique symbol;
|
||||
declare const realtimeEndpointIdBrand: unique symbol;
|
||||
declare const externalEventEffectProfileIdBrand: unique symbol;
|
||||
declare const killSwitchIdBrand: unique symbol;
|
||||
|
||||
export type StreamRegistrationId = string &
|
||||
Readonly<{ [streamRegistrationIdBrand]: true }>;
|
||||
export type EventTypeId = string &
|
||||
Readonly<{ [eventTypeIdBrand]: true }>;
|
||||
export type RealtimeEndpointId = string &
|
||||
Readonly<{ [realtimeEndpointIdBrand]: true }>;
|
||||
export type ExternalEventEffectProfileId = string &
|
||||
Readonly<{ [externalEventEffectProfileIdBrand]: true }>;
|
||||
export type KillSwitchId = string &
|
||||
Readonly<{ [killSwitchIdBrand]: true }>;
|
||||
|
||||
export const REALTIME_EVENT_PROTOCOL = "REALTIME_EVENT_V1" as const;
|
||||
|
||||
export const REALTIME_HARD_LIMITS = Object.freeze({
|
||||
maxEventBytes: 64 * 1024,
|
||||
maxPayloadDepth: 16,
|
||||
maxPayloadNodes: 4_096,
|
||||
maxQueueEvents: 256,
|
||||
maxQueueBytes: 4 * 1024 * 1024,
|
||||
maxDedupeEntries: 2_048,
|
||||
maxDedupeBytes: 4 * 1024 * 1024,
|
||||
dedupeTtlMs: 10 * 60 * 1_000,
|
||||
});
|
||||
|
||||
export type RealtimeLimits = Readonly<{
|
||||
maxEventBytes: number;
|
||||
maxPayloadDepth: number;
|
||||
maxPayloadNodes: number;
|
||||
maxQueueEvents: number;
|
||||
maxQueueBytes: number;
|
||||
maxDedupeEntries: number;
|
||||
/**
|
||||
* Additional implementation memory ceiling for semantic conflict
|
||||
* fingerprints. Reaching it has the same recovery meaning as exhausting the
|
||||
* count/time dedupe window.
|
||||
*/
|
||||
maxDedupeBytes: number;
|
||||
dedupeTtlMs: number;
|
||||
}>;
|
||||
|
||||
export type RealtimeRecoveryProfile =
|
||||
| Readonly<{
|
||||
mode: "CURSOR";
|
||||
snapshotOperationId: string;
|
||||
checkpointCodecId: string;
|
||||
barrier: "REPLAY";
|
||||
}>
|
||||
| Readonly<{
|
||||
mode: "SNAPSHOT_ONLY";
|
||||
snapshotOperationId: string;
|
||||
checkpointCodecId: string;
|
||||
barrier: "CONNECT_BUFFER" | "SERVER_HOLD" | "NONE";
|
||||
}>
|
||||
| Readonly<{
|
||||
mode: "SESSION_REBUILD";
|
||||
rebuildInputId: string;
|
||||
}>;
|
||||
|
||||
export type RealtimeStreamRegistration = Readonly<{
|
||||
id: StreamRegistrationId;
|
||||
protocol: typeof REALTIME_EVENT_PROTOCOL;
|
||||
owner: string;
|
||||
scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND";
|
||||
primaryTransport: "SSE" | "WEBSOCKET" | "NONE";
|
||||
endpointId: RealtimeEndpointId;
|
||||
eventTypeIds: readonly EventTypeId[];
|
||||
delivery: "INVALIDATION_HINT" | "AUTHORITATIVE_DELTA" | "EPHEMERAL";
|
||||
recovery: RealtimeRecoveryProfile;
|
||||
fallback: "BOUNDED_POLLING" | "EXPLICITLY_STALE";
|
||||
hiddenPolicy: "CLOSE" | "BOUNDED_GRACE";
|
||||
limits: RealtimeLimits;
|
||||
killSwitchId: KillSwitchId;
|
||||
}>;
|
||||
|
||||
export type RealtimeEventTypeRegistration = Readonly<{
|
||||
id: EventTypeId;
|
||||
owner: string;
|
||||
payloadSchemaId: string;
|
||||
mapperId: string;
|
||||
effectProfileId: ExternalEventEffectProfileId;
|
||||
stateBearing: boolean;
|
||||
}>;
|
||||
|
||||
export type RealtimePolicyRegistryBindings = Readonly<{
|
||||
schemaCodecs: Readonly<Record<string, RuntimeSchemaCodec>>;
|
||||
mappers: Readonly<Record<string, InstalledBoundaryMapper>>;
|
||||
apiOperations: Readonly<Record<string, ApiOperation>>;
|
||||
endpointIds: readonly RealtimeEndpointId[];
|
||||
effectProfileIds: readonly ExternalEventEffectProfileId[];
|
||||
killSwitchIds: readonly KillSwitchId[];
|
||||
rebuildInputIds?: readonly string[];
|
||||
}>;
|
||||
|
||||
export type RealtimePolicyRegistry = Readonly<{
|
||||
findStream(id: string): RealtimeStreamRegistration | undefined;
|
||||
findEventType(id: string): RealtimeEventTypeRegistration | undefined;
|
||||
findStreamEventType(
|
||||
streamId: string,
|
||||
eventTypeId: string,
|
||||
): RealtimeEventTypeRegistration | undefined;
|
||||
listStreams(): readonly RealtimeStreamRegistration[];
|
||||
listEventTypes(): readonly RealtimeEventTypeRegistration[];
|
||||
}>;
|
||||
|
||||
export type RealtimePolicyRegistryInput = Readonly<{
|
||||
streams: readonly RealtimeStreamRegistration[];
|
||||
eventTypes: readonly RealtimeEventTypeRegistration[];
|
||||
bindings: RealtimePolicyRegistryBindings;
|
||||
}>;
|
||||
|
||||
const REGISTRY_ID = /^[A-Z][A-Z0-9_]{2,79}$/u;
|
||||
const OWNED_ID = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u;
|
||||
const OWNER = /^[a-z0-9][a-z0-9._:-]{0,127}$/u;
|
||||
|
||||
const STREAM_KEYS = Object.freeze([
|
||||
"delivery",
|
||||
"endpointId",
|
||||
"eventTypeIds",
|
||||
"fallback",
|
||||
"hiddenPolicy",
|
||||
"id",
|
||||
"killSwitchId",
|
||||
"limits",
|
||||
"owner",
|
||||
"primaryTransport",
|
||||
"protocol",
|
||||
"recovery",
|
||||
"scope",
|
||||
] as const);
|
||||
const EVENT_TYPE_KEYS = Object.freeze([
|
||||
"effectProfileId",
|
||||
"id",
|
||||
"mapperId",
|
||||
"owner",
|
||||
"payloadSchemaId",
|
||||
"stateBearing",
|
||||
] as const);
|
||||
const LIMIT_KEYS = Object.freeze([
|
||||
"dedupeTtlMs",
|
||||
"maxDedupeBytes",
|
||||
"maxDedupeEntries",
|
||||
"maxEventBytes",
|
||||
"maxPayloadDepth",
|
||||
"maxPayloadNodes",
|
||||
"maxQueueBytes",
|
||||
"maxQueueEvents",
|
||||
] as const);
|
||||
|
||||
export function defineStreamRegistrationId(
|
||||
value: string,
|
||||
): StreamRegistrationId {
|
||||
return defineRegistryId(value, "stream") as StreamRegistrationId;
|
||||
}
|
||||
|
||||
export function defineEventTypeId(value: string): EventTypeId {
|
||||
return defineRegistryId(value, "event type") as EventTypeId;
|
||||
}
|
||||
|
||||
export function defineRealtimeEndpointId(
|
||||
value: string,
|
||||
): RealtimeEndpointId {
|
||||
return defineRegistryId(value, "endpoint") as RealtimeEndpointId;
|
||||
}
|
||||
|
||||
export function defineExternalEventEffectProfileId(
|
||||
value: string,
|
||||
): ExternalEventEffectProfileId {
|
||||
return defineRegistryId(
|
||||
value,
|
||||
"effect profile",
|
||||
) as ExternalEventEffectProfileId;
|
||||
}
|
||||
|
||||
export function defineRealtimeKillSwitchId(value: string): KillSwitchId {
|
||||
return defineRegistryId(value, "kill switch") as KillSwitchId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a composition-time registry and retains no caller-owned registration
|
||||
* object or array.
|
||||
*/
|
||||
export function createRealtimePolicyRegistry(
|
||||
input: RealtimePolicyRegistryInput,
|
||||
): RealtimePolicyRegistry {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== "object" ||
|
||||
!Array.isArray(input.streams) ||
|
||||
input.streams.length < 1 ||
|
||||
input.streams.length > 128 ||
|
||||
!Array.isArray(input.eventTypes) ||
|
||||
input.eventTypes.length < 1 ||
|
||||
input.eventTypes.length > 512
|
||||
) {
|
||||
throw new TypeError("Realtime policy registry is invalid.");
|
||||
}
|
||||
|
||||
const endpointIds = identifierSet(
|
||||
input.bindings.endpointIds,
|
||||
"endpoint",
|
||||
);
|
||||
const effectProfileIds = identifierSet(
|
||||
input.bindings.effectProfileIds,
|
||||
"effect profile",
|
||||
);
|
||||
const killSwitchIds = identifierSet(
|
||||
input.bindings.killSwitchIds,
|
||||
"kill switch",
|
||||
);
|
||||
const rebuildInputIds = ownedIdentifierSet(
|
||||
input.bindings.rebuildInputIds ?? [],
|
||||
"rebuild input",
|
||||
);
|
||||
|
||||
const eventTypes = new Map<EventTypeId, RealtimeEventTypeRegistration>();
|
||||
for (const candidate of input.eventTypes) {
|
||||
const registration = snapshotEventType(
|
||||
candidate,
|
||||
input.bindings,
|
||||
effectProfileIds,
|
||||
);
|
||||
if (eventTypes.has(registration.id)) {
|
||||
throw new TypeError("Realtime event type is duplicated.");
|
||||
}
|
||||
eventTypes.set(registration.id, registration);
|
||||
}
|
||||
|
||||
const streams = new Map<StreamRegistrationId, RealtimeStreamRegistration>();
|
||||
const referencedEventTypes = new Set<EventTypeId>();
|
||||
for (const candidate of input.streams) {
|
||||
const registration = snapshotStream(
|
||||
candidate,
|
||||
input.bindings,
|
||||
eventTypes,
|
||||
endpointIds,
|
||||
killSwitchIds,
|
||||
rebuildInputIds,
|
||||
);
|
||||
if (streams.has(registration.id)) {
|
||||
throw new TypeError("Realtime stream is duplicated.");
|
||||
}
|
||||
streams.set(registration.id, registration);
|
||||
for (const eventTypeId of registration.eventTypeIds) {
|
||||
referencedEventTypes.add(eventTypeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
[...eventTypes.keys()].some(
|
||||
(eventTypeId) => !referencedEventTypes.has(eventTypeId),
|
||||
)
|
||||
) {
|
||||
throw new TypeError("Realtime event type is not owned by a stream.");
|
||||
}
|
||||
|
||||
const streamList = Object.freeze([...streams.values()]);
|
||||
const eventTypeList = Object.freeze([...eventTypes.values()]);
|
||||
|
||||
return Object.freeze({
|
||||
findStream(id: string) {
|
||||
return streams.get(id as StreamRegistrationId);
|
||||
},
|
||||
findEventType(id: string) {
|
||||
return eventTypes.get(id as EventTypeId);
|
||||
},
|
||||
findStreamEventType(streamId: string, eventTypeId: string) {
|
||||
const stream = streams.get(streamId as StreamRegistrationId);
|
||||
if (!stream || !stream.eventTypeIds.includes(eventTypeId as EventTypeId)) {
|
||||
return undefined;
|
||||
}
|
||||
return eventTypes.get(eventTypeId as EventTypeId);
|
||||
},
|
||||
listStreams: () => streamList,
|
||||
listEventTypes: () => eventTypeList,
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotEventType(
|
||||
input: RealtimeEventTypeRegistration,
|
||||
bindings: RealtimePolicyRegistryBindings,
|
||||
effectProfileIds: ReadonlySet<string>,
|
||||
): RealtimeEventTypeRegistration {
|
||||
if (
|
||||
!hasExactKeys(input, EVENT_TYPE_KEYS) ||
|
||||
!REGISTRY_ID.test(input.id) ||
|
||||
!OWNER.test(input.owner) ||
|
||||
!OWNED_ID.test(input.payloadSchemaId) ||
|
||||
!OWNED_ID.test(input.mapperId) ||
|
||||
!REGISTRY_ID.test(input.effectProfileId) ||
|
||||
typeof input.stateBearing !== "boolean" ||
|
||||
bindings.schemaCodecs[input.payloadSchemaId]?.schemaId !==
|
||||
input.payloadSchemaId ||
|
||||
bindings.mappers[input.mapperId]?.mapperId !== input.mapperId ||
|
||||
bindings.mappers[input.mapperId]?.inputSchemaId !==
|
||||
input.payloadSchemaId ||
|
||||
!effectProfileIds.has(input.effectProfileId)
|
||||
) {
|
||||
throw new TypeError("Realtime event type registration is invalid.");
|
||||
}
|
||||
return Object.freeze({ ...input });
|
||||
}
|
||||
|
||||
function snapshotStream(
|
||||
input: RealtimeStreamRegistration,
|
||||
bindings: RealtimePolicyRegistryBindings,
|
||||
eventTypes: ReadonlyMap<EventTypeId, RealtimeEventTypeRegistration>,
|
||||
endpointIds: ReadonlySet<string>,
|
||||
killSwitchIds: ReadonlySet<string>,
|
||||
rebuildInputIds: ReadonlySet<string>,
|
||||
): RealtimeStreamRegistration {
|
||||
if (
|
||||
!hasExactKeys(input, STREAM_KEYS) ||
|
||||
!REGISTRY_ID.test(input.id) ||
|
||||
input.protocol !== REALTIME_EVENT_PROTOCOL ||
|
||||
!OWNER.test(input.owner) ||
|
||||
!["ORIGIN_SHARED", "ACCOUNT_BOUND", "SESSION_BOUND"].includes(
|
||||
input.scope,
|
||||
) ||
|
||||
!["SSE", "WEBSOCKET", "NONE"].includes(input.primaryTransport) ||
|
||||
!REGISTRY_ID.test(input.endpointId) ||
|
||||
!endpointIds.has(input.endpointId) ||
|
||||
!Array.isArray(input.eventTypeIds) ||
|
||||
input.eventTypeIds.length < 1 ||
|
||||
input.eventTypeIds.length > 128 ||
|
||||
new Set(input.eventTypeIds).size !== input.eventTypeIds.length ||
|
||||
input.eventTypeIds.some(
|
||||
(eventTypeId) =>
|
||||
!REGISTRY_ID.test(eventTypeId) || !eventTypes.has(eventTypeId),
|
||||
) ||
|
||||
!["INVALIDATION_HINT", "AUTHORITATIVE_DELTA", "EPHEMERAL"].includes(
|
||||
input.delivery,
|
||||
) ||
|
||||
!["BOUNDED_POLLING", "EXPLICITLY_STALE"].includes(input.fallback) ||
|
||||
!["CLOSE", "BOUNDED_GRACE"].includes(input.hiddenPolicy) ||
|
||||
!REGISTRY_ID.test(input.killSwitchId) ||
|
||||
!killSwitchIds.has(input.killSwitchId)
|
||||
) {
|
||||
throw new TypeError("Realtime stream registration is invalid.");
|
||||
}
|
||||
|
||||
const limits = snapshotLimits(input.limits);
|
||||
const recovery = snapshotRecovery(
|
||||
input.recovery,
|
||||
bindings,
|
||||
rebuildInputIds,
|
||||
);
|
||||
const selectedEventTypes = input.eventTypeIds.map((eventTypeId) => {
|
||||
const selected = eventTypes.get(eventTypeId);
|
||||
if (!selected) {
|
||||
throw new TypeError("Realtime stream event type is unresolved.");
|
||||
}
|
||||
return selected;
|
||||
});
|
||||
const hasStateBearingEvent = selectedEventTypes.some(
|
||||
(eventType) => eventType.stateBearing,
|
||||
);
|
||||
const hasNonStateBearingEvent = selectedEventTypes.some(
|
||||
(eventType) => !eventType.stateBearing,
|
||||
);
|
||||
|
||||
if (
|
||||
(hasStateBearingEvent && recovery.mode === "SESSION_REBUILD") ||
|
||||
(hasStateBearingEvent &&
|
||||
recovery.mode === "SNAPSHOT_ONLY" &&
|
||||
recovery.barrier === "NONE") ||
|
||||
(input.delivery === "EPHEMERAL" && hasStateBearingEvent) ||
|
||||
(input.delivery === "AUTHORITATIVE_DELTA" &&
|
||||
hasNonStateBearingEvent) ||
|
||||
(recovery.mode === "SESSION_REBUILD" &&
|
||||
input.delivery !== "EPHEMERAL") ||
|
||||
(input.delivery === "EPHEMERAL" &&
|
||||
input.fallback === "BOUNDED_POLLING")
|
||||
) {
|
||||
throw new TypeError("Realtime stream recovery contract is contradictory.");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...input,
|
||||
eventTypeIds: Object.freeze([...input.eventTypeIds]),
|
||||
recovery,
|
||||
limits,
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotRecovery(
|
||||
input: RealtimeRecoveryProfile,
|
||||
bindings: RealtimePolicyRegistryBindings,
|
||||
rebuildInputIds: ReadonlySet<string>,
|
||||
): RealtimeRecoveryProfile {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new TypeError("Realtime recovery profile is invalid.");
|
||||
}
|
||||
if (input.mode === "CURSOR") {
|
||||
if (
|
||||
!hasExactKeys(input, [
|
||||
"barrier",
|
||||
"checkpointCodecId",
|
||||
"mode",
|
||||
"snapshotOperationId",
|
||||
]) ||
|
||||
input.barrier !== "REPLAY" ||
|
||||
!validSnapshotBindings(input, bindings)
|
||||
) {
|
||||
throw new TypeError("Realtime cursor recovery profile is invalid.");
|
||||
}
|
||||
return Object.freeze({ ...input });
|
||||
}
|
||||
if (input.mode === "SNAPSHOT_ONLY") {
|
||||
if (
|
||||
!hasExactKeys(input, [
|
||||
"barrier",
|
||||
"checkpointCodecId",
|
||||
"mode",
|
||||
"snapshotOperationId",
|
||||
]) ||
|
||||
!["CONNECT_BUFFER", "SERVER_HOLD", "NONE"].includes(input.barrier) ||
|
||||
!validSnapshotBindings(input, bindings)
|
||||
) {
|
||||
throw new TypeError("Realtime snapshot recovery profile is invalid.");
|
||||
}
|
||||
return Object.freeze({ ...input });
|
||||
}
|
||||
if (
|
||||
input.mode !== "SESSION_REBUILD" ||
|
||||
!hasExactKeys(input, ["mode", "rebuildInputId"]) ||
|
||||
!OWNED_ID.test(input.rebuildInputId) ||
|
||||
!rebuildInputIds.has(input.rebuildInputId)
|
||||
) {
|
||||
throw new TypeError("Realtime session rebuild profile is invalid.");
|
||||
}
|
||||
return Object.freeze({ ...input });
|
||||
}
|
||||
|
||||
function validSnapshotBindings(
|
||||
input: Readonly<{
|
||||
snapshotOperationId: string;
|
||||
checkpointCodecId: string;
|
||||
}>,
|
||||
bindings: RealtimePolicyRegistryBindings,
|
||||
): boolean {
|
||||
const operation = bindings.apiOperations[input.snapshotOperationId];
|
||||
return (
|
||||
OWNED_ID.test(input.snapshotOperationId) &&
|
||||
OWNED_ID.test(input.checkpointCodecId) &&
|
||||
bindings.schemaCodecs[input.checkpointCodecId]?.schemaId ===
|
||||
input.checkpointCodecId &&
|
||||
operation?.contractVersion === 2 &&
|
||||
operation.protocol === "REST" &&
|
||||
operation.semantics === "QUERY" &&
|
||||
(operation.replayPolicy === "SAFE" ||
|
||||
operation.replayPolicy === "IDEMPOTENT")
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotLimits(input: RealtimeLimits): RealtimeLimits {
|
||||
if (!hasExactKeys(input, LIMIT_KEYS)) {
|
||||
throw new TypeError("Realtime limits are invalid.");
|
||||
}
|
||||
for (const key of LIMIT_KEYS) {
|
||||
const value = input[key];
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 1 ||
|
||||
value > REALTIME_HARD_LIMITS[key]
|
||||
) {
|
||||
throw new TypeError("Realtime limits exceed implementation ceilings.");
|
||||
}
|
||||
}
|
||||
if (
|
||||
input.maxQueueBytes < input.maxEventBytes ||
|
||||
input.maxDedupeBytes < input.maxEventBytes
|
||||
) {
|
||||
throw new TypeError("Realtime memory limits cannot hold one event.");
|
||||
}
|
||||
return Object.freeze({ ...input });
|
||||
}
|
||||
|
||||
function identifierSet(
|
||||
values: readonly string[],
|
||||
label: string,
|
||||
): ReadonlySet<string> {
|
||||
if (!Array.isArray(values)) {
|
||||
throw new TypeError(`Realtime ${label} bindings are invalid.`);
|
||||
}
|
||||
const result = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (!REGISTRY_ID.test(value) || result.has(value)) {
|
||||
throw new TypeError(`Realtime ${label} bindings are invalid.`);
|
||||
}
|
||||
result.add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ownedIdentifierSet(
|
||||
values: readonly string[],
|
||||
label: string,
|
||||
): ReadonlySet<string> {
|
||||
if (!Array.isArray(values)) {
|
||||
throw new TypeError(`Realtime ${label} bindings are invalid.`);
|
||||
}
|
||||
const result = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (!OWNED_ID.test(value) || result.has(value)) {
|
||||
throw new TypeError(`Realtime ${label} bindings are invalid.`);
|
||||
}
|
||||
result.add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function defineRegistryId(value: string, label: string): string {
|
||||
if (!REGISTRY_ID.test(value)) {
|
||||
throw new TypeError(`Realtime ${label} ID is invalid.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasExactKeys(
|
||||
value: unknown,
|
||||
expected: readonly string[],
|
||||
): value is Readonly<Record<string, unknown>> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const selected = [...expected].sort();
|
||||
return (
|
||||
keys.length === selected.length &&
|
||||
keys.every((key, index) => key === selected[index])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { contractSetSchema } from "./contract-set.ts";
|
||||
|
||||
const versionSchema = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
|
||||
|
||||
function assertEndpointUrl(
|
||||
value: string,
|
||||
local: boolean,
|
||||
options: Readonly<{ trailingSlashPath?: boolean }> = {},
|
||||
): void {
|
||||
const parsed = new URL(value);
|
||||
if (
|
||||
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.hash ||
|
||||
parsed.search ||
|
||||
(!local && parsed.protocol !== "https:")
|
||||
) {
|
||||
throw new TypeError("invalid");
|
||||
}
|
||||
if (options.trailingSlashPath && !parsed.pathname.endsWith("/")) {
|
||||
throw new TypeError("invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidReleaseManifestUrl(value: string): boolean {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return false;
|
||||
if (new TextEncoder().encode(value).byteLength > 256) return false;
|
||||
if (value.includes("?") || value.includes("#") || value.includes("\\")) {
|
||||
return false;
|
||||
}
|
||||
if (/%2f|%5c/i.test(value)) return false;
|
||||
return !value
|
||||
.split("/")
|
||||
.some((segment) => segment === "." || segment === "..");
|
||||
}
|
||||
|
||||
export const capabilityOverrideArtifactSchema = z
|
||||
.object({
|
||||
REALTIME: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"),
|
||||
WEB_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"),
|
||||
SERVICE_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"),
|
||||
OFFLINE_COMMANDS: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"),
|
||||
})
|
||||
.strict()
|
||||
.default({
|
||||
REALTIME: "DEFAULT",
|
||||
WEB_WORKER: "DEFAULT",
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
});
|
||||
|
||||
type RuntimeConfigArtifactDraft = Readonly<{
|
||||
APP_ENV: "local" | "development" | "staging" | "production";
|
||||
API_BASE_URL: string;
|
||||
TELEMETRY_ENABLED: boolean;
|
||||
TELEMETRY_ENDPOINT?: string;
|
||||
AUTH_MODE: "external" | "demo";
|
||||
RELEASE_MANIFEST_URL: string;
|
||||
}>;
|
||||
|
||||
function runtimeConfigArtifactInvariants(
|
||||
config: RuntimeConfigArtifactDraft,
|
||||
context: z.RefinementCtx,
|
||||
): void {
|
||||
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
|
||||
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["TELEMETRY_ENDPOINT"],
|
||||
message: "required when telemetry is enabled",
|
||||
});
|
||||
}
|
||||
if (!local && config.AUTH_MODE === "demo") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["AUTH_MODE"],
|
||||
message: "demo authentication is limited to local environments",
|
||||
});
|
||||
}
|
||||
try {
|
||||
assertEndpointUrl(config.API_BASE_URL, local, { trailingSlashPath: true });
|
||||
} catch {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["API_BASE_URL"],
|
||||
message:
|
||||
"absolute credential-free URL ending in / is required; HTTPS outside local",
|
||||
});
|
||||
}
|
||||
if (config.TELEMETRY_ENDPOINT) {
|
||||
try {
|
||||
assertEndpointUrl(config.TELEMETRY_ENDPOINT, local);
|
||||
} catch {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["TELEMETRY_ENDPOINT"],
|
||||
message:
|
||||
"absolute credential-free URL is required; HTTPS outside local",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!isValidReleaseManifestUrl(config.RELEASE_MANIFEST_URL)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["RELEASE_MANIFEST_URL"],
|
||||
message: "same-origin absolute path without query, hash or traversal",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeConfigArtifactFields = {
|
||||
APP_ENV: z.enum(["local", "development", "staging", "production"]),
|
||||
API_BASE_URL: z.url(),
|
||||
REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000),
|
||||
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
|
||||
TELEMETRY_ENABLED: z.boolean(),
|
||||
TELEMETRY_ENDPOINT: z.url().optional(),
|
||||
AUTH_MODE: z.enum(["external", "demo"]),
|
||||
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
|
||||
RELEASE_ID: z.string().min(1).optional(),
|
||||
BUILD_ID: z.string().min(1).optional(),
|
||||
} as const;
|
||||
|
||||
export const runtimeConfigV1ArtifactSchema = z
|
||||
.object({
|
||||
...runtimeConfigArtifactFields,
|
||||
CONFIG_SCHEMA_VERSION: z.literal("1"),
|
||||
API_CONTRACT_VERSION: versionSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(runtimeConfigArtifactInvariants);
|
||||
|
||||
export const runtimeConfigV2ArtifactSchema = z
|
||||
.object({
|
||||
...runtimeConfigArtifactFields,
|
||||
CONFIG_SCHEMA_VERSION: z.literal("2.0"),
|
||||
CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(runtimeConfigArtifactInvariants);
|
||||
|
||||
export const runtimeConfigArtifactSchema = z.discriminatedUnion(
|
||||
"CONFIG_SCHEMA_VERSION",
|
||||
[
|
||||
runtimeConfigV1ArtifactSchema,
|
||||
runtimeConfigV2ArtifactSchema,
|
||||
],
|
||||
);
|
||||
|
||||
const releaseManifestArtifactFields = {
|
||||
appVersion: z.string().min(1),
|
||||
buildId: z.string().min(1),
|
||||
commitSha: z.string().min(1),
|
||||
assetManifestHash: z.string().min(1),
|
||||
releaseId: z.string().min(1),
|
||||
builtAt: z.string().min(1),
|
||||
routeChunks: z.record(z.string().min(1), z.string().min(1)),
|
||||
} as const;
|
||||
|
||||
export const releaseManifestV1ArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
...releaseManifestArtifactFields,
|
||||
configSchemaVersion: versionSchema,
|
||||
apiContractVersion: versionSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const releaseManifestV2ArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
...releaseManifestArtifactFields,
|
||||
configSchemaVersion: z.literal("2.0"),
|
||||
contractSet: contractSetSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const releaseManifestArtifactSchema = z.discriminatedUnion(
|
||||
"schemaVersion",
|
||||
[releaseManifestV1ArtifactSchema, releaseManifestV2ArtifactSchema],
|
||||
);
|
||||
|
||||
export const buildManifestArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
buildId: z.string().min(1),
|
||||
commitSha: z.string().min(1),
|
||||
releaseId: z.string().min(1),
|
||||
moduleInventoryHash: z.string().min(1),
|
||||
generatedAt: z.string().min(1),
|
||||
buildContext: z
|
||||
.object({
|
||||
nodeVersion: z.string().min(1),
|
||||
packageManagerVersion: z.string().min(1),
|
||||
runnerImage: z.string().min(1),
|
||||
sourceDateEpoch: z.string().min(1).nullable(),
|
||||
})
|
||||
.strict(),
|
||||
outputs: z
|
||||
.object({
|
||||
directory: z.string().min(1),
|
||||
viteManifest: z.string().min(1),
|
||||
moduleInventory: z.string().min(1),
|
||||
routeChunks: z.record(z.string().min(1), z.string().min(1)),
|
||||
runtimeConfigSchema: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type RuntimeConfigV1Artifact = z.output<
|
||||
typeof runtimeConfigV1ArtifactSchema
|
||||
>;
|
||||
export type RuntimeConfigV2Artifact = z.output<
|
||||
typeof runtimeConfigV2ArtifactSchema
|
||||
>;
|
||||
export type CapabilityOverrideArtifact = z.output<
|
||||
typeof capabilityOverrideArtifactSchema
|
||||
>;
|
||||
export type RuntimeConfigArtifact = z.output<typeof runtimeConfigArtifactSchema>;
|
||||
export type ReleaseManifestV1Artifact = z.output<
|
||||
typeof releaseManifestV1ArtifactSchema
|
||||
>;
|
||||
export type ReleaseManifestV2Artifact = z.output<
|
||||
typeof releaseManifestV2ArtifactSchema
|
||||
>;
|
||||
export type ReleaseArtifact = z.output<typeof releaseManifestArtifactSchema>;
|
||||
export type BuildManifestArtifact = z.output<typeof buildManifestArtifactSchema>;
|
||||
|
||||
export function parseReleaseArtifact(value: unknown): ReleaseArtifact {
|
||||
return releaseManifestArtifactSchema.parse(value);
|
||||
}
|
||||
|
||||
export function parseRuntimeConfigArtifact(value: unknown): RuntimeConfigArtifact {
|
||||
return runtimeConfigArtifactSchema.parse(value);
|
||||
}
|
||||
|
||||
export function parseBuildManifestArtifact(value: unknown): BuildManifestArtifact {
|
||||
return buildManifestArtifactSchema.parse(value);
|
||||
}
|
||||
|
||||
export function projectReleaseTokens(release: ReleaseArtifact) {
|
||||
const common = {
|
||||
schemaVersion: release.schemaVersion,
|
||||
appVersion: release.appVersion,
|
||||
buildId: release.buildId,
|
||||
commitSha: release.commitSha,
|
||||
configSchemaVersion: release.configSchemaVersion,
|
||||
assetManifestHash: release.assetManifestHash,
|
||||
releaseId: release.releaseId,
|
||||
builtAt: release.builtAt,
|
||||
} as const;
|
||||
|
||||
return release.schemaVersion === 1
|
||||
? Object.freeze({
|
||||
...common,
|
||||
schemaVersion: 1 as const,
|
||||
apiContractVersion: release.apiContractVersion,
|
||||
})
|
||||
: Object.freeze({
|
||||
...common,
|
||||
schemaVersion: 2 as const,
|
||||
contractSetDigest: release.contractSet.setDigest,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { verifyCompatibilityTuple } from "../application/policies/compatibility.ts";
|
||||
|
||||
export const RELEASE_TOKEN_REGISTRY = Object.freeze({
|
||||
appVersion: token("appVersion", "manifest", "human release label"),
|
||||
buildId: token("buildId", "CI build", "asset and HTML coherence"),
|
||||
commitSha: token("commitSha", "VCS", "source traceability"),
|
||||
configSchemaVersion: token(
|
||||
"configSchemaVersion",
|
||||
"runtime config schema",
|
||||
"boot compatibility",
|
||||
),
|
||||
apiContractVersion: token(
|
||||
"apiContractVersion",
|
||||
"frontend/backend agreement",
|
||||
"legacy V1 scalar; superseded by contractSetDigest",
|
||||
),
|
||||
contractSetDigest: token(
|
||||
"contractSetDigest",
|
||||
"compiled external contract package set",
|
||||
"release coherence for multi-package contracts",
|
||||
),
|
||||
assetManifestHash: token(
|
||||
"assetManifestHash",
|
||||
"build output",
|
||||
"chunk integrity and mismatch detection",
|
||||
),
|
||||
releaseId: token("releaseId", "deploy system", "rollback target"),
|
||||
builtAt: token("builtAt", "CI", "diagnostics only; never cache identity"),
|
||||
});
|
||||
|
||||
function token(name: string, source: string, compatibilityRole: string) {
|
||||
return Object.freeze({ token: name, source, compatibilityRole });
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a release manifest and runtime configuration structurally. Version
|
||||
* fields are delegated to the numeric compatibility policy, never compared
|
||||
* lexically.
|
||||
*
|
||||
*/
|
||||
export function compareReleaseToRuntime(
|
||||
release: Readonly<{
|
||||
buildId: string;
|
||||
configSchemaVersion: string;
|
||||
apiContractVersion: string;
|
||||
assetManifestHash: string;
|
||||
releaseId: string;
|
||||
}>,
|
||||
runtimeConfig: Readonly<{
|
||||
BUILD_ID: string;
|
||||
CONFIG_SCHEMA_VERSION: string;
|
||||
API_CONTRACT_VERSION: string;
|
||||
RELEASE_ID: string;
|
||||
}>,
|
||||
) {
|
||||
return verifyCompatibilityTuple({
|
||||
frontend: release,
|
||||
runtime: {
|
||||
buildId: runtimeConfig.BUILD_ID,
|
||||
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
|
||||
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
|
||||
assetManifestHash: release.assetManifestHash,
|
||||
releaseId: runtimeConfig.RELEASE_ID,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
export type FetchCredentialsMode = "omit" | "same-origin" | "include";
|
||||
|
||||
export type RestProviderProfile = Readonly<{
|
||||
providerId: string;
|
||||
baseUrl: string;
|
||||
allowedCredentialsModes: readonly FetchCredentialsMode[];
|
||||
redirect: "error";
|
||||
referrerPolicy: "no-referrer";
|
||||
}>;
|
||||
|
||||
export type RestAuthProfile = Readonly<{
|
||||
authProfileId: string;
|
||||
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
|
||||
credentials: FetchCredentialsMode;
|
||||
allowedCredentialHeaders: readonly ("authorization" | "x-csrf-token")[];
|
||||
}>;
|
||||
|
||||
export type RestCsrfProfile = Readonly<{
|
||||
csrfProfileId: string;
|
||||
mode: "NONE" | "HEADER";
|
||||
headerName: "x-csrf-token" | null;
|
||||
}>;
|
||||
|
||||
export const REST_AUTH_PROFILES = Object.freeze({
|
||||
REFERENCE_EXTERNAL_BEARER: Object.freeze({
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
transport: "BEARER_HEADER",
|
||||
credentials: "omit",
|
||||
allowedCredentialHeaders: Object.freeze(["authorization"] as const),
|
||||
}),
|
||||
ANONYMOUS: Object.freeze({
|
||||
authProfileId: "ANONYMOUS",
|
||||
transport: "ANONYMOUS",
|
||||
credentials: "omit",
|
||||
allowedCredentialHeaders: Object.freeze([]),
|
||||
}),
|
||||
} satisfies Readonly<Record<string, RestAuthProfile>>);
|
||||
|
||||
export const REST_CSRF_PROFILES = Object.freeze({
|
||||
NO_CSRF_BEARER: Object.freeze({
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
mode: "NONE",
|
||||
headerName: null,
|
||||
}),
|
||||
} satisfies Readonly<Record<string, RestCsrfProfile>>);
|
||||
|
||||
export function createRestProviderProfile(
|
||||
providerId: string,
|
||||
baseUrl: string,
|
||||
allowedCredentialsModes: readonly FetchCredentialsMode[] = ["omit"],
|
||||
): RestProviderProfile {
|
||||
const parsed = new URL(baseUrl);
|
||||
const localHttp =
|
||||
parsed.protocol === "http:" &&
|
||||
["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
|
||||
if (
|
||||
!providerId ||
|
||||
(parsed.protocol !== "https:" && !localHttp) ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash ||
|
||||
allowedCredentialsModes.length === 0 ||
|
||||
new Set(allowedCredentialsModes).size !== allowedCredentialsModes.length
|
||||
) {
|
||||
throw new TypeError("Invalid REST provider profile.");
|
||||
}
|
||||
return Object.freeze({
|
||||
providerId,
|
||||
baseUrl: parsed.href,
|
||||
allowedCredentialsModes: Object.freeze([...allowedCredentialsModes]),
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRestSecurityProfiles(
|
||||
operation: Readonly<{
|
||||
method: string;
|
||||
auth: "none" | "external-session";
|
||||
authProfileId?: string;
|
||||
csrfProfileId?: string;
|
||||
}>,
|
||||
provider: RestProviderProfile,
|
||||
authProfiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
|
||||
csrfProfiles: Readonly<Record<string, RestCsrfProfile>> = REST_CSRF_PROFILES,
|
||||
): Readonly<{ auth: RestAuthProfile; csrf: RestCsrfProfile }> {
|
||||
const auth = authProfiles[operation.authProfileId ?? ""];
|
||||
const csrf = csrfProfiles[operation.csrfProfileId ?? ""];
|
||||
const unsafe = !["GET", "HEAD", "OPTIONS"].includes(operation.method);
|
||||
if (
|
||||
!auth ||
|
||||
!csrf ||
|
||||
!provider.allowedCredentialsModes.includes(auth.credentials) ||
|
||||
(operation.auth === "none" && auth.transport !== "ANONYMOUS") ||
|
||||
(operation.auth === "external-session" &&
|
||||
auth.transport === "ANONYMOUS") ||
|
||||
(auth.transport === "BEARER_HEADER" && csrf.mode !== "NONE") ||
|
||||
(unsafe &&
|
||||
auth.transport === "SAME_ORIGIN_COOKIE" &&
|
||||
csrf.mode !== "HEADER")
|
||||
) {
|
||||
throw new TypeError("REST security profiles are incoherent.");
|
||||
}
|
||||
return Object.freeze({ auth, csrf });
|
||||
}
|
||||
|
||||
export function validateRestProfileBindings(
|
||||
operations: Readonly<
|
||||
Record<
|
||||
string,
|
||||
Readonly<{
|
||||
contractVersion?: number;
|
||||
operationId: string;
|
||||
method: string;
|
||||
auth: "none" | "external-session";
|
||||
providerId?: string;
|
||||
authProfileId?: string;
|
||||
csrfProfileId?: string;
|
||||
}>
|
||||
>
|
||||
>,
|
||||
providerCredentialModes: Readonly<
|
||||
Record<string, readonly FetchCredentialsMode[]>
|
||||
>,
|
||||
authProfiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
|
||||
csrfProfiles: Readonly<Record<string, RestCsrfProfile>> = REST_CSRF_PROFILES,
|
||||
): true {
|
||||
for (const operation of Object.values(operations)) {
|
||||
if (operation.contractVersion !== 2) continue;
|
||||
const allowed = providerCredentialModes[operation.providerId ?? ""];
|
||||
if (!allowed) {
|
||||
throw new TypeError(
|
||||
`Unregistered REST provider binding: ${operation.operationId}`,
|
||||
);
|
||||
}
|
||||
resolveRestSecurityProfiles(
|
||||
operation,
|
||||
Object.freeze({
|
||||
providerId: operation.providerId ?? "",
|
||||
baseUrl: "https://contract.invalid/",
|
||||
allowedCredentialsModes: allowed,
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
}),
|
||||
authProfiles,
|
||||
csrfProfiles,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type RouteCodecId = "none" | "NotFoundSplat";
|
||||
export type RouteRuntimeDefinition = Readonly<{
|
||||
routeId: string;
|
||||
moduleId: string;
|
||||
paramsCodec: RouteCodecId;
|
||||
searchCodec: RouteCodecId;
|
||||
}>;
|
||||
|
||||
const runtime = <Definition extends RouteRuntimeDefinition>(
|
||||
value: Definition,
|
||||
): Readonly<Definition> => Object.freeze(value);
|
||||
|
||||
export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({
|
||||
APP_HOME: runtime({
|
||||
routeId: "APP_HOME",
|
||||
moduleId: "home-page",
|
||||
paramsCodec: "none",
|
||||
searchCodec: "none",
|
||||
}),
|
||||
EXAMPLES_PLATFORM: runtime({
|
||||
routeId: "EXAMPLES_PLATFORM",
|
||||
moduleId: "platform-overview-page",
|
||||
paramsCodec: "none",
|
||||
searchCodec: "none",
|
||||
}),
|
||||
EXAMPLES_UI: runtime({
|
||||
routeId: "EXAMPLES_UI",
|
||||
moduleId: "ui-gallery-page",
|
||||
paramsCodec: "none",
|
||||
searchCodec: "none",
|
||||
}),
|
||||
EXAMPLES_STATES: runtime({
|
||||
routeId: "EXAMPLES_STATES",
|
||||
moduleId: "state-gallery-page",
|
||||
paramsCodec: "none",
|
||||
searchCodec: "none",
|
||||
}),
|
||||
EXAMPLES_AUTH: runtime({
|
||||
routeId: "EXAMPLES_AUTH",
|
||||
moduleId: "auth-example-page",
|
||||
paramsCodec: "none",
|
||||
searchCodec: "none",
|
||||
}),
|
||||
NOT_FOUND: runtime({
|
||||
routeId: "NOT_FOUND",
|
||||
moduleId: "not-found-page",
|
||||
paramsCodec: "NotFoundSplat",
|
||||
searchCodec: "none",
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
export type RouteDefinition = Readonly<{
|
||||
routeId: string;
|
||||
path: string;
|
||||
paramsSchema: string | null;
|
||||
searchSchema: string | null;
|
||||
access: "public" | "session-required";
|
||||
loadingSurface: string;
|
||||
errorSurface: string;
|
||||
chunkId: string;
|
||||
title: string;
|
||||
navigationLabel: string | null;
|
||||
navigationOrder: number | null;
|
||||
}>;
|
||||
|
||||
const route = <Definition extends RouteDefinition>(
|
||||
definition: Definition,
|
||||
): Readonly<Definition> => Object.freeze(definition);
|
||||
|
||||
export const PLATFORM_ROUTE_REGISTRY = Object.freeze({
|
||||
APP_HOME: route({
|
||||
routeId: "APP_HOME",
|
||||
path: "/",
|
||||
paramsSchema: null,
|
||||
searchSchema: null,
|
||||
access: "public",
|
||||
loadingSurface: "app-shell",
|
||||
errorSurface: "route-boundary",
|
||||
chunkId: "route-home",
|
||||
title: "시작",
|
||||
navigationLabel: "시작",
|
||||
navigationOrder: 10,
|
||||
}),
|
||||
EXAMPLES_PLATFORM: route({
|
||||
routeId: "EXAMPLES_PLATFORM",
|
||||
path: "/examples/platform",
|
||||
paramsSchema: null,
|
||||
searchSchema: null,
|
||||
access: "public",
|
||||
loadingSurface: "example-page",
|
||||
errorSurface: "route-boundary",
|
||||
chunkId: "route-examples-platform",
|
||||
title: "플랫폼 구성",
|
||||
navigationLabel: "플랫폼 구성",
|
||||
navigationOrder: 15,
|
||||
}),
|
||||
EXAMPLES_UI: route({
|
||||
routeId: "EXAMPLES_UI",
|
||||
path: "/examples/ui",
|
||||
paramsSchema: null,
|
||||
searchSchema: null,
|
||||
access: "public",
|
||||
loadingSurface: "example-page",
|
||||
errorSurface: "route-boundary",
|
||||
chunkId: "route-examples-ui",
|
||||
title: "UI 구성요소",
|
||||
navigationLabel: "UI 구성요소",
|
||||
navigationOrder: 20,
|
||||
}),
|
||||
EXAMPLES_STATES: route({
|
||||
routeId: "EXAMPLES_STATES",
|
||||
path: "/examples/states",
|
||||
paramsSchema: null,
|
||||
searchSchema: null,
|
||||
access: "public",
|
||||
loadingSurface: "example-page",
|
||||
errorSurface: "route-boundary",
|
||||
chunkId: "route-examples-states",
|
||||
title: "화면 상태",
|
||||
navigationLabel: "화면 상태",
|
||||
navigationOrder: 30,
|
||||
}),
|
||||
EXAMPLES_AUTH: route({
|
||||
routeId: "EXAMPLES_AUTH",
|
||||
path: "/examples/auth",
|
||||
paramsSchema: null,
|
||||
searchSchema: null,
|
||||
access: "public",
|
||||
loadingSurface: "example-page",
|
||||
errorSurface: "route-boundary",
|
||||
chunkId: "route-examples-auth",
|
||||
title: "인증 연동",
|
||||
navigationLabel: "인증 연동",
|
||||
navigationOrder: 40,
|
||||
}),
|
||||
NOT_FOUND: route({
|
||||
routeId: "NOT_FOUND",
|
||||
path: "*",
|
||||
paramsSchema: "NotFoundSplat",
|
||||
searchSchema: null,
|
||||
access: "public",
|
||||
loadingSurface: "none",
|
||||
errorSurface: "not-found",
|
||||
chunkId: "route-not-found",
|
||||
title: "페이지를 찾을 수 없음",
|
||||
navigationLabel: null,
|
||||
navigationOrder: null,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { InstalledOfflineCommandContribution } from "./offline-command.ts";
|
||||
import type { InstalledServiceWorkerSelection } from "./service-worker.ts";
|
||||
import type { InstalledWebWorkerContribution } from "./web-worker.ts";
|
||||
|
||||
/**
|
||||
* §3.4–§3.6. Optional runtime capability selection and hosting.
|
||||
*
|
||||
* Source contribution decides what is installed. Runtime Config may only carry
|
||||
* `DEFAULT | DISABLED`, so a configuration document can never switch on a
|
||||
* capability whose source is absent.
|
||||
*/
|
||||
|
||||
export type RuntimeCapabilityOverride = "DEFAULT" | "DISABLED";
|
||||
|
||||
export type RuntimeStopReason =
|
||||
| "APPLICATION_SHUTDOWN"
|
||||
| "SCOPE_FENCED"
|
||||
| "FEATURE_DISABLED"
|
||||
| "HIDDEN_POLICY"
|
||||
| "INCIDENT_CONTAINMENT";
|
||||
|
||||
export interface RuntimeLifecycle {
|
||||
start(): void | Promise<void>;
|
||||
stop(reason: RuntimeStopReason): void | Promise<void>;
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** §20.2. `FAILED -> STARTING` is never automatic. */
|
||||
export type RuntimeLifecycleState =
|
||||
| "NEW"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPING"
|
||||
| "STOPPED"
|
||||
| "FAILED"
|
||||
| "DISPOSING"
|
||||
| "DISPOSED";
|
||||
|
||||
/** §22.12. Health is per capability; there is no global `healthy` boolean. */
|
||||
export type RuntimeHealth =
|
||||
| "AVAILABLE"
|
||||
| "DEGRADED"
|
||||
| "UNAVAILABLE"
|
||||
| "INCOMPATIBLE"
|
||||
| "DISABLED";
|
||||
|
||||
// §13.2. Realtime product contribution.
|
||||
export type RealtimeEffectKind =
|
||||
| "INVALIDATE_TOPICS"
|
||||
| "APPLY_AUTHORITATIVE_DELTA"
|
||||
| "EPHEMERAL_NOTIFICATION";
|
||||
|
||||
export interface InstalledRealtimeEventEffect {
|
||||
readonly eventType: string;
|
||||
readonly mapperId: string;
|
||||
readonly effect: RealtimeEffectKind;
|
||||
readonly invalidationTopics: readonly string[];
|
||||
}
|
||||
|
||||
export type InstalledRealtimeTransport =
|
||||
| Readonly<{ kind: "SSE"; endpointId: string }>
|
||||
| Readonly<{ kind: "WEBSOCKET"; endpointId: string }>
|
||||
| Readonly<{ kind: "POLLING"; operationId: string; intervalMs: number }>;
|
||||
|
||||
export interface InstalledRealtimeContribution {
|
||||
readonly contributionId: string;
|
||||
readonly featureId: string;
|
||||
readonly contractSourcePackageId: string;
|
||||
readonly streamId: string;
|
||||
readonly recoveryMode: "CURSOR" | "SNAPSHOT_ONLY" | "SESSION_REBUILD";
|
||||
readonly eventEffects: readonly InstalledRealtimeEventEffect[];
|
||||
readonly transport: InstalledRealtimeTransport;
|
||||
}
|
||||
|
||||
export const REALTIME_CONTRIBUTION_BOUNDS = Object.freeze({
|
||||
contributions: 64,
|
||||
streams: 128,
|
||||
eventTypes: 512,
|
||||
effectsPerEvent: 8,
|
||||
invalidationTopicsPerEvent: 32,
|
||||
minimumPollingIntervalMs: 5_000,
|
||||
maximumPollingIntervalMs: 300_000,
|
||||
});
|
||||
|
||||
export interface InstalledRuntimeCapabilities {
|
||||
readonly realtime: readonly InstalledRealtimeContribution[];
|
||||
readonly webWorkers: readonly InstalledWebWorkerContribution[];
|
||||
readonly serviceWorker: InstalledServiceWorkerSelection | null;
|
||||
readonly offlineCommands: InstalledOfflineCommandContribution | null;
|
||||
}
|
||||
|
||||
export type CapabilityOverrideMap = Readonly<{
|
||||
REALTIME: RuntimeCapabilityOverride;
|
||||
WEB_WORKER: RuntimeCapabilityOverride;
|
||||
SERVICE_WORKER: RuntimeCapabilityOverride;
|
||||
OFFLINE_COMMANDS: RuntimeCapabilityOverride;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The effective selection after applying runtime overrides. `serviceWorkerMode`
|
||||
* keeps the §3.6 persistent-registration exception explicit: a statically
|
||||
* `ACTIVE` worker that runtime config disables still performs exactly one
|
||||
* owned-registration lookup and at most one unregister, and deletes no cache.
|
||||
*/
|
||||
export type ResolvedRuntimeCapabilities = Readonly<{
|
||||
realtime: readonly InstalledRealtimeContribution[];
|
||||
webWorkers: readonly InstalledWebWorkerContribution[];
|
||||
serviceWorker: InstalledServiceWorkerSelection | null;
|
||||
serviceWorkerDisabledCleanup: boolean;
|
||||
offlineCommands: InstalledOfflineCommandContribution | null;
|
||||
}>;
|
||||
|
||||
export function resolveRuntimeCapabilities(
|
||||
installed: InstalledRuntimeCapabilities,
|
||||
overrides: CapabilityOverrideMap,
|
||||
): ResolvedRuntimeCapabilities {
|
||||
const realtimeDisabled = overrides.REALTIME === "DISABLED";
|
||||
const workersDisabled = overrides.WEB_WORKER === "DISABLED";
|
||||
const serviceWorkerDisabled = overrides.SERVICE_WORKER === "DISABLED";
|
||||
const offlineDisabled = overrides.OFFLINE_COMMANDS === "DISABLED";
|
||||
|
||||
return Object.freeze({
|
||||
realtime: realtimeDisabled ? Object.freeze([]) : installed.realtime,
|
||||
webWorkers: workersDisabled ? Object.freeze([]) : installed.webWorkers,
|
||||
serviceWorker: serviceWorkerDisabled ? null : installed.serviceWorker,
|
||||
serviceWorkerDisabledCleanup:
|
||||
serviceWorkerDisabled && installed.serviceWorker?.mode === "ACTIVE",
|
||||
offlineCommands: offlineDisabled ? null : installed.offlineCommands,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateRealtimeContributions(
|
||||
contributions: readonly InstalledRealtimeContribution[],
|
||||
): readonly InstalledRealtimeContribution[] {
|
||||
const bounds = REALTIME_CONTRIBUTION_BOUNDS;
|
||||
if (contributions.length > bounds.contributions) {
|
||||
throw new TypeError("Realtime contributions exceed their bound.");
|
||||
}
|
||||
const contributionIds = new Set<string>();
|
||||
const streamIds = new Set<string>();
|
||||
let eventTypeCount = 0;
|
||||
|
||||
for (const contribution of contributions) {
|
||||
if (
|
||||
!contribution.contributionId ||
|
||||
contributionIds.has(contribution.contributionId)
|
||||
) {
|
||||
throw new TypeError("Duplicate realtime contribution identity.");
|
||||
}
|
||||
contributionIds.add(contribution.contributionId);
|
||||
streamIds.add(contribution.streamId);
|
||||
if (streamIds.size > bounds.streams) {
|
||||
throw new TypeError("Realtime streams exceed their bound.");
|
||||
}
|
||||
|
||||
const transport = contribution.transport;
|
||||
if (transport.kind === "POLLING") {
|
||||
if (
|
||||
!Number.isSafeInteger(transport.intervalMs) ||
|
||||
transport.intervalMs < bounds.minimumPollingIntervalMs ||
|
||||
transport.intervalMs > bounds.maximumPollingIntervalMs
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Realtime polling interval is out of range: ${contribution.contributionId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const seenEvents = new Map<string, number>();
|
||||
for (const effect of contribution.eventEffects) {
|
||||
eventTypeCount += 1;
|
||||
if (eventTypeCount > bounds.eventTypes) {
|
||||
throw new TypeError("Realtime event types exceed their bound.");
|
||||
}
|
||||
const count = (seenEvents.get(effect.eventType) ?? 0) + 1;
|
||||
if (count > bounds.effectsPerEvent) {
|
||||
throw new TypeError(
|
||||
`Realtime effects per event exceeded: ${effect.eventType}`,
|
||||
);
|
||||
}
|
||||
seenEvents.set(effect.eventType, count);
|
||||
if (
|
||||
effect.invalidationTopics.length > bounds.invalidationTopicsPerEvent
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Realtime invalidation topics exceeded: ${effect.eventType}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
effect.effect === "INVALIDATE_TOPICS" &&
|
||||
effect.invalidationTopics.length === 0
|
||||
) {
|
||||
throw new TypeError(
|
||||
`INVALIDATE_TOPICS effect declares no topic: ${effect.eventType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze([...contributions]);
|
||||
}
|
||||
|
||||
export type RuntimeCapabilityId =
|
||||
| "REALTIME"
|
||||
| "WEB_WORKER"
|
||||
| "SERVICE_WORKER"
|
||||
| "OFFLINE_COMMANDS";
|
||||
|
||||
/**
|
||||
* §3.5. A bounded, serialisable view of one capability. `selected` is the
|
||||
* static SSOT count and `active` is what survived the runtime override, so the
|
||||
* difference between the two is exactly the operator's effect. An override can
|
||||
* only subtract, which is why a never-selected capability stays at zero.
|
||||
*/
|
||||
export type RuntimeCapabilityStatus = Readonly<{
|
||||
capabilityId: RuntimeCapabilityId;
|
||||
selected: number;
|
||||
active: number;
|
||||
override: RuntimeCapabilityOverride;
|
||||
}>;
|
||||
|
||||
export type RuntimeCapabilitySnapshot = readonly RuntimeCapabilityStatus[];
|
||||
|
||||
const CAPABILITY_ORDER = Object.freeze([
|
||||
"REALTIME",
|
||||
"WEB_WORKER",
|
||||
"SERVICE_WORKER",
|
||||
"OFFLINE_COMMANDS",
|
||||
] as const);
|
||||
|
||||
export function describeRuntimeCapabilities(
|
||||
installed: InstalledRuntimeCapabilities,
|
||||
overrides: CapabilityOverrideMap,
|
||||
): RuntimeCapabilitySnapshot {
|
||||
const resolved = resolveRuntimeCapabilities(installed, overrides);
|
||||
const counts: Readonly<
|
||||
Record<RuntimeCapabilityId, Readonly<{ selected: number; active: number }>>
|
||||
> = Object.freeze({
|
||||
REALTIME: Object.freeze({
|
||||
selected: installed.realtime.length,
|
||||
active: resolved.realtime.length,
|
||||
}),
|
||||
WEB_WORKER: Object.freeze({
|
||||
selected: installed.webWorkers.length,
|
||||
active: resolved.webWorkers.length,
|
||||
}),
|
||||
SERVICE_WORKER: Object.freeze({
|
||||
selected: installed.serviceWorker === null ? 0 : 1,
|
||||
active: resolved.serviceWorker === null ? 0 : 1,
|
||||
}),
|
||||
OFFLINE_COMMANDS: Object.freeze({
|
||||
selected: installed.offlineCommands === null ? 0 : 1,
|
||||
active: resolved.offlineCommands === null ? 0 : 1,
|
||||
}),
|
||||
});
|
||||
|
||||
return Object.freeze(
|
||||
CAPABILITY_ORDER.map((capabilityId) =>
|
||||
Object.freeze({
|
||||
capabilityId,
|
||||
selected: counts[capabilityId].selected,
|
||||
active: counts[capabilityId].active,
|
||||
override: overrides[capabilityId],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export type SchemaDefinition = Readonly<{
|
||||
schemaId: string;
|
||||
boundary:
|
||||
| "route-params"
|
||||
| "route-search"
|
||||
| "route-search-api-request"
|
||||
| "api-request"
|
||||
| "api-response";
|
||||
owner: string;
|
||||
runtime: "zod";
|
||||
schemaVersion?: number;
|
||||
direction?: "REQUEST" | "RESPONSE";
|
||||
unknownFieldPolicy?: "REJECT_UNKNOWN" | "STRIP_UNKNOWN";
|
||||
}>;
|
||||
|
||||
export type RuntimeSchemaResult =
|
||||
| Readonly<{ success: true; data: unknown }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
issues: readonly Readonly<{ path: string; code: string }>[];
|
||||
}>;
|
||||
|
||||
export type RuntimeSchemaCodec = Readonly<{
|
||||
schemaId: string;
|
||||
parse(value: unknown): RuntimeSchemaResult;
|
||||
}>;
|
||||
|
||||
export function composeRuntimeSchemaCodecs(
|
||||
contributions: readonly Readonly<Record<string, RuntimeSchemaCodec>>[],
|
||||
): Readonly<Record<string, RuntimeSchemaCodec>> {
|
||||
const result: Record<string, RuntimeSchemaCodec> = Object.create(null);
|
||||
for (const contribution of contributions) {
|
||||
for (const [registryId, codec] of Object.entries(contribution)) {
|
||||
if (
|
||||
registryId !== codec.schemaId ||
|
||||
Object.hasOwn(result, registryId)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Invalid or duplicate runtime schema codec: ${registryId}`,
|
||||
);
|
||||
}
|
||||
result[registryId] = codec;
|
||||
}
|
||||
}
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
export function validateWithRuntimeSchemaRegistry(
|
||||
schemaId: string,
|
||||
value: unknown,
|
||||
registry: Readonly<Record<string, RuntimeSchemaCodec>>,
|
||||
): RuntimeSchemaResult {
|
||||
const codec = registry[schemaId];
|
||||
if (!codec) {
|
||||
return Object.freeze({
|
||||
success: false,
|
||||
issues: Object.freeze([
|
||||
Object.freeze({ path: "", code: "SCHEMA_NOT_REGISTERED" }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
try {
|
||||
return codec.parse(value);
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
success: false,
|
||||
issues: Object.freeze([
|
||||
Object.freeze({ path: "", code: "SCHEMA_EXECUTION_FAILED" }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function composeSchemaRegistry(
|
||||
contributions: readonly Readonly<Record<string, SchemaDefinition>>[],
|
||||
): Readonly<Record<string, SchemaDefinition>> {
|
||||
const result: Record<string, SchemaDefinition> = Object.create(null);
|
||||
for (const contribution of contributions) {
|
||||
for (const [registryId, definition] of Object.entries(contribution)) {
|
||||
if (
|
||||
registryId !== definition.schemaId ||
|
||||
!definition.owner ||
|
||||
(definition.schemaVersion !== undefined &&
|
||||
(!Number.isSafeInteger(definition.schemaVersion) ||
|
||||
definition.schemaVersion < 1)) ||
|
||||
Object.hasOwn(result, registryId)
|
||||
) {
|
||||
throw new TypeError(`Invalid or duplicate schema definition: ${registryId}`);
|
||||
}
|
||||
result[registryId] = definition;
|
||||
}
|
||||
}
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
export const PLATFORM_SCHEMA_REGISTRY: Readonly<
|
||||
Record<string, SchemaDefinition>
|
||||
> = Object.freeze({
|
||||
none: Object.freeze({
|
||||
schemaId: "none",
|
||||
boundary: "route-params",
|
||||
owner: "feature-frontend-routing-release-recovery-runtime",
|
||||
runtime: "zod",
|
||||
}),
|
||||
NotFoundSplat: Object.freeze({
|
||||
schemaId: "NotFoundSplat",
|
||||
boundary: "route-params",
|
||||
owner: "feature-frontend-routing-release-recovery-runtime",
|
||||
runtime: "zod",
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RuntimeIdentityRegistry } from "./query-keys.ts";
|
||||
|
||||
export type CacheScopeSnapshot = Readonly<{
|
||||
generation: number;
|
||||
fingerprint: string;
|
||||
identities: RuntimeIdentityRegistry;
|
||||
/** Aborted synchronously when this generation is fenced or disposed. */
|
||||
signal: AbortSignal;
|
||||
isCurrent(): boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §10.5. Client scope authority lifecycle.
|
||||
*
|
||||
* `FENCED` is published synchronously so no subscriber can render a value that
|
||||
* belonged to the previous identity. `READY` arrives only after the exact reset
|
||||
* sequence in §10.6 has completed.
|
||||
*/
|
||||
export type ClientScopeLifecycleEvent =
|
||||
| Readonly<{ kind: "FENCED"; previousGeneration: number }>
|
||||
| Readonly<{ kind: "READY"; snapshot: CacheScopeSnapshot }>
|
||||
| Readonly<{ kind: "FAILED"; generation: number }>
|
||||
| Readonly<{ kind: "DISPOSED" }>;
|
||||
|
||||
/** UI reads `FENCED` as the `scope-transition` state, never as stale data. */
|
||||
export type ClientScopePhase = "READY" | "FENCED" | "FAILED" | "DISPOSED";
|
||||
|
||||
export type ServerStateScopeRuntime = Readonly<{
|
||||
getSnapshot(): CacheScopeSnapshot;
|
||||
getPhase(): ClientScopePhase;
|
||||
subscribe(listener: () => void): () => void;
|
||||
subscribeLifecycle(
|
||||
listener: (event: ClientScopeLifecycleEvent) => void,
|
||||
): () => void;
|
||||
dispose(): void;
|
||||
}>;
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { Result } from "../application/result.ts";
|
||||
import type { QueryInvalidationTopic } from "./query-invalidation.ts";
|
||||
import {
|
||||
createBoundQueryKey,
|
||||
defineQueryNamespaceIdentity,
|
||||
type RuntimeIdentityBinding,
|
||||
} from "./query-keys.ts";
|
||||
import type { CacheScopeSnapshot } from "./server-state-scope.ts";
|
||||
import type { MutationIntent } from "./mutation-intent.ts";
|
||||
|
||||
/**
|
||||
* §10.2. The four fixed profiles. A feature selects one by ID; it never
|
||||
* declares its own numbers. If none of the four can express a requirement, the
|
||||
* design document and this registry are amended together.
|
||||
*/
|
||||
export type ServerStateProfileId =
|
||||
| "DETAIL_STANDARD"
|
||||
| "LIST_STANDARD"
|
||||
| "LOOKUP_STABLE"
|
||||
| "VOLATILE_STATUS";
|
||||
|
||||
export type ServerStateProfile = Readonly<{
|
||||
profileId: string;
|
||||
staleTimeMs: number;
|
||||
gcTimeMs: number;
|
||||
refetchOnMount: boolean | "always";
|
||||
refetchOnFocus: boolean;
|
||||
refetchOnReconnect: boolean;
|
||||
retryOwner: "TRANSPORT" | "QUERY" | "NONE";
|
||||
maxResultItems: number;
|
||||
maxEstimatedResultBytes: number;
|
||||
}>;
|
||||
|
||||
export const SERVER_STATE_PROFILES: Readonly<
|
||||
Record<ServerStateProfileId, ServerStateProfile>
|
||||
> = Object.freeze({
|
||||
DETAIL_STANDARD: Object.freeze({
|
||||
profileId: "DETAIL_STANDARD",
|
||||
staleTimeMs: 30_000,
|
||||
gcTimeMs: 300_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
retryOwner: "TRANSPORT",
|
||||
maxResultItems: 1,
|
||||
maxEstimatedResultBytes: 262_144,
|
||||
}),
|
||||
LIST_STANDARD: Object.freeze({
|
||||
profileId: "LIST_STANDARD",
|
||||
staleTimeMs: 15_000,
|
||||
gcTimeMs: 300_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
retryOwner: "TRANSPORT",
|
||||
maxResultItems: 200,
|
||||
maxEstimatedResultBytes: 1_048_576,
|
||||
}),
|
||||
LOOKUP_STABLE: Object.freeze({
|
||||
profileId: "LOOKUP_STABLE",
|
||||
staleTimeMs: 300_000,
|
||||
gcTimeMs: 1_800_000,
|
||||
refetchOnMount: false,
|
||||
refetchOnFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
retryOwner: "TRANSPORT",
|
||||
maxResultItems: 500,
|
||||
maxEstimatedResultBytes: 2_097_152,
|
||||
}),
|
||||
VOLATILE_STATUS: Object.freeze({
|
||||
profileId: "VOLATILE_STATUS",
|
||||
staleTimeMs: 0,
|
||||
gcTimeMs: 60_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
retryOwner: "TRANSPORT",
|
||||
maxResultItems: 1,
|
||||
maxEstimatedResultBytes: 65_536,
|
||||
}),
|
||||
});
|
||||
|
||||
export function getServerStateProfile(
|
||||
profileId: ServerStateProfileId,
|
||||
): ServerStateProfile {
|
||||
const profile = SERVER_STATE_PROFILES[profileId];
|
||||
if (!profile) {
|
||||
throw new TypeError(`Unregistered server-state profile: ${profileId}`);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** §10.3. Feature-owned, mandatory result measurement. */
|
||||
export type QueryResultMeasure = Readonly<{
|
||||
itemCount: number;
|
||||
estimatedBytes: number;
|
||||
}>;
|
||||
|
||||
export type ResultAdmission =
|
||||
| Readonly<{ ok: true; measure: QueryResultMeasure }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
code: "RESULT_MEASUREMENT_FAILED" | "RESULT_BUDGET_EXCEEDED";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §10.4. There is no generic fallback: `JSON.stringify` sizing, wire DTO
|
||||
* re-serialization and recursive walkers are all prohibited, so a definition
|
||||
* without a working `measureResult` fails closed instead of guessing.
|
||||
*/
|
||||
export function admitQueryResult<Value>(
|
||||
measureResult: (value: Value) => QueryResultMeasure,
|
||||
value: Value,
|
||||
profile: ServerStateProfile,
|
||||
): ResultAdmission {
|
||||
let measure: QueryResultMeasure;
|
||||
try {
|
||||
measure = measureResult(value);
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "RESULT_MEASUREMENT_FAILED" as const,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!measure ||
|
||||
!Number.isSafeInteger(measure.itemCount) ||
|
||||
measure.itemCount < 0 ||
|
||||
!Number.isSafeInteger(measure.estimatedBytes) ||
|
||||
measure.estimatedBytes < 0
|
||||
) {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "RESULT_MEASUREMENT_FAILED" as const,
|
||||
});
|
||||
}
|
||||
if (
|
||||
measure.itemCount > profile.maxResultItems ||
|
||||
measure.estimatedBytes > profile.maxEstimatedResultBytes
|
||||
) {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "RESULT_BUDGET_EXCEEDED" as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ ok: true as const, measure: Object.freeze(measure) });
|
||||
}
|
||||
|
||||
export type BoundQuery<Value> = Readonly<{
|
||||
definitionId: string;
|
||||
queryKey: readonly unknown[];
|
||||
profile: ServerStateProfile;
|
||||
identity: RuntimeIdentityBinding;
|
||||
scope: CacheScopeSnapshot;
|
||||
measureResult(value: Value): QueryResultMeasure;
|
||||
execute(context: Readonly<{ signal: AbortSignal }>): Promise<Result<Value>>;
|
||||
}>;
|
||||
|
||||
export type QueryDefinition<Input, Value> = Readonly<{
|
||||
definitionId: string;
|
||||
definitionVersion: number;
|
||||
owner: string;
|
||||
namespace: string;
|
||||
namespaceVersion: number;
|
||||
operationId: string;
|
||||
profileId: ServerStateProfileId;
|
||||
measureResult(value: Value): QueryResultMeasure;
|
||||
execute(
|
||||
input: Input,
|
||||
context: Readonly<{ signal: AbortSignal }>,
|
||||
): Promise<Result<Value>>;
|
||||
}>;
|
||||
|
||||
export function bindQuery<Input, Value>(
|
||||
definition: QueryDefinition<Input, Value>,
|
||||
input: Input,
|
||||
scope: CacheScopeSnapshot,
|
||||
): BoundQuery<Value> {
|
||||
if (typeof definition.measureResult !== "function") {
|
||||
throw new TypeError(
|
||||
`Query definition requires measureResult: ${definition.definitionId}`,
|
||||
);
|
||||
}
|
||||
const namespace = defineQueryNamespaceIdentity(
|
||||
definition.namespace,
|
||||
definition.namespaceVersion,
|
||||
);
|
||||
const identity = scope.identities.intern(input);
|
||||
return Object.freeze({
|
||||
definitionId: definition.definitionId,
|
||||
// §10.7. Opaque runtime identity only. No raw account/resource ID, URL,
|
||||
// filter object, document or cursor ever enters a query key.
|
||||
queryKey: createBoundQueryKey(
|
||||
namespace,
|
||||
scope.fingerprint,
|
||||
definition.definitionVersion,
|
||||
identity.token,
|
||||
),
|
||||
profile: getServerStateProfile(definition.profileId),
|
||||
identity,
|
||||
scope,
|
||||
measureResult: (value: Value) => definition.measureResult(value),
|
||||
execute: (context) => definition.execute(input, context),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* §11.2. `REJECT_WHILE_ACTIVE` is the command default. `JOIN_IDENTICAL` is
|
||||
* only valid when scope, definition, canonical input and user intent all match;
|
||||
* `ALLOW_PARALLEL` is an explicit per-feature decision.
|
||||
*/
|
||||
export type MutationDuplicatePolicy =
|
||||
| "JOIN_IDENTICAL"
|
||||
| "REJECT_WHILE_ACTIVE"
|
||||
| "ALLOW_PARALLEL";
|
||||
|
||||
/** §11.5. Ordered optimistic layer bounds. Overflow means pessimistic execution. */
|
||||
export const OPTIMISTIC_LAYER_BOUNDS = Object.freeze({
|
||||
maxLayersPerQueryKey: 8,
|
||||
maxSingleLayerBytes: 262_144,
|
||||
maxTotalLayerBytesPerQueryKey: 2_097_152,
|
||||
maxLayerAgeGraceMs: 60_000,
|
||||
});
|
||||
|
||||
/** §11.3. Duplicate coordinator bounds. The map is never used as a result cache. */
|
||||
export const MUTATION_COORDINATOR_BOUNDS = Object.freeze({
|
||||
activeDefinitionsPerRuntime: 256,
|
||||
activeIntentsTotal: 1_024,
|
||||
canonicalIdentityBytes: 16_384,
|
||||
waitersPerJoinedIntent: 32,
|
||||
settledRetentionMs: 0,
|
||||
});
|
||||
|
||||
export type BoundMutation<Input, Value> = Readonly<{
|
||||
definitionId: string;
|
||||
definitionVersion: number;
|
||||
operationId: string;
|
||||
requiresIdempotencyKey: boolean;
|
||||
owner: string;
|
||||
duplicatePolicy: MutationDuplicatePolicy;
|
||||
scope: CacheScopeSnapshot;
|
||||
execute(
|
||||
input: Input,
|
||||
context: Readonly<{ signal: AbortSignal; intent: MutationIntent }>,
|
||||
): Promise<Result<Value>>;
|
||||
invalidate: readonly QueryInvalidationTopic[];
|
||||
optimistic?: Readonly<{
|
||||
queryKey: readonly unknown[];
|
||||
update(previous: unknown, input: Input): unknown;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export function defineServerStateProfile(
|
||||
profile: ServerStateProfile,
|
||||
): ServerStateProfile {
|
||||
if (
|
||||
!profile.profileId ||
|
||||
!Number.isSafeInteger(profile.staleTimeMs) ||
|
||||
profile.staleTimeMs < 0 ||
|
||||
!Number.isSafeInteger(profile.gcTimeMs) ||
|
||||
profile.gcTimeMs < 1 ||
|
||||
profile.retryOwner === "QUERY" ||
|
||||
!Number.isSafeInteger(profile.maxResultItems) ||
|
||||
profile.maxResultItems < 1 ||
|
||||
!Number.isSafeInteger(profile.maxEstimatedResultBytes) ||
|
||||
profile.maxEstimatedResultBytes < 1
|
||||
) {
|
||||
throw new TypeError("Invalid server-state profile.");
|
||||
}
|
||||
return Object.freeze({ ...profile });
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* §17–§18. Service Worker contract.
|
||||
*
|
||||
* One physical registration per scope covers PWA lifecycle, verified static
|
||||
* asset fetch, Web Push and the optional sync wake-up. A separate registration
|
||||
* for any of those is prohibited.
|
||||
*/
|
||||
|
||||
export const SERVICE_WORKER_PROTOCOL_VERSION = 1 as const;
|
||||
export const SERVICE_WORKER_CACHE_SCHEMA_VERSION = 1 as const;
|
||||
export const SERVICE_WORKER_SCRIPT_PATH = "service-worker.js" as const;
|
||||
|
||||
export const SERVICE_WORKER_BOUNDS = Object.freeze({
|
||||
assets: 256,
|
||||
singleAssetBytes: 2 * 1024 * 1024,
|
||||
assetSetBytes: 5 * 1024 * 1024,
|
||||
fetchConcurrency: 4,
|
||||
installDeadlineMs: 60_000,
|
||||
clientDrainMs: 30_000,
|
||||
updateCheckIntervalMs: 6 * 60 * 60 * 1_000,
|
||||
retainedPreviousCaches: 1,
|
||||
reloadGuardBytes: 512,
|
||||
reloadGuardTtlMs: 10 * 60 * 1_000,
|
||||
});
|
||||
|
||||
export type ServiceWorkerHandlerId =
|
||||
| "WEB_PUSH"
|
||||
| "PWA_STATIC_ASSETS"
|
||||
| "OFFLINE_SYNC_WAKEUP";
|
||||
|
||||
/**
|
||||
* §17.3. Removal is staged. `ACTIVE` never transitions directly to `null`:
|
||||
* a registration that already exists in a browser must first be unregistered,
|
||||
* then have its owned resources purged, before the source may disappear.
|
||||
*/
|
||||
export type InstalledServiceWorkerSelection =
|
||||
| Readonly<{
|
||||
mode: "ACTIVE";
|
||||
scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH;
|
||||
handlers: readonly ServiceWorkerHandlerId[];
|
||||
}>
|
||||
| Readonly<{
|
||||
mode: "REMOVE_REGISTRATION";
|
||||
scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH;
|
||||
}>
|
||||
| Readonly<{
|
||||
mode: "PURGE_OWNED_RESOURCES";
|
||||
scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH;
|
||||
}>;
|
||||
|
||||
/** §17.7. Page and worker read the same compile-time identity tuple. */
|
||||
export type ServiceWorkerProtocolIdentity = Readonly<{
|
||||
serviceWorkerProtocolVersion: typeof SERVICE_WORKER_PROTOCOL_VERSION;
|
||||
cacheSchemaVersion: typeof SERVICE_WORKER_CACHE_SCHEMA_VERSION;
|
||||
buildId: string;
|
||||
releaseId: string;
|
||||
contractSetDigest: string;
|
||||
staticAssetSetDigest: string;
|
||||
}>;
|
||||
|
||||
export type ServiceWorkerMessageKind =
|
||||
| "PAGE_HELLO"
|
||||
| "WORKER_HELLO_ACK"
|
||||
| "UPDATE_READY"
|
||||
| "ACTIVATE_REQUEST"
|
||||
| "ACTIVATE_ACCEPTED"
|
||||
| "ACTIVATE_REJECTED"
|
||||
| "CLIENT_DRAIN_REQUEST"
|
||||
| "CLIENT_DRAINED"
|
||||
| "ACTIVATED_RELOAD_REQUIRED"
|
||||
| "CACHE_RESET_REQUEST"
|
||||
| "CACHE_RESET_RESULT"
|
||||
| "SYNC_WAKE_OBSERVED";
|
||||
|
||||
export type ServiceWorkerMessage = Readonly<{
|
||||
protocolVersion: typeof SERVICE_WORKER_PROTOCOL_VERSION;
|
||||
kind: ServiceWorkerMessageKind;
|
||||
messageId: string;
|
||||
sourceBuildId: string;
|
||||
targetBuildId?: string;
|
||||
nonce?: string;
|
||||
/** Present only on a successful CACHE_RESET_RESULT. */
|
||||
cachesDeleted?: number;
|
||||
}>;
|
||||
|
||||
/** §18.3. Compile-time asset manifest; the worker never fetches one. */
|
||||
export interface StaticAssetManifestV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly buildId: string;
|
||||
readonly releaseId: string;
|
||||
readonly setDigest: `sha256:${string}`;
|
||||
readonly assets: readonly Readonly<{
|
||||
url: string;
|
||||
sha256: `sha256:${string}`;
|
||||
bytes: number;
|
||||
contentType: string;
|
||||
}>[];
|
||||
}
|
||||
|
||||
/** §18.2. `ca-static-v1-<first 16 lower-hex of staticAssetSetDigest>`. */
|
||||
export const STATIC_CACHE_PREFIX = "ca-static-v1-" as const;
|
||||
|
||||
export function staticCacheName(setDigest: string): string {
|
||||
const hex = setDigest.replace(/^sha256:/, "").slice(0, 16);
|
||||
if (!/^[0-9a-f]{16}$/.test(hex)) {
|
||||
throw new TypeError("Static asset set digest is invalid.");
|
||||
}
|
||||
return `${STATIC_CACHE_PREFIX}${hex}`;
|
||||
}
|
||||
|
||||
export function isOwnedStaticCacheName(name: string): boolean {
|
||||
return (
|
||||
name.startsWith(STATIC_CACHE_PREFIX) &&
|
||||
/^[0-9a-f]{16}$/.test(name.slice(STATIC_CACHE_PREFIX.length))
|
||||
);
|
||||
}
|
||||
|
||||
export type ServiceWorkerStartOutcome =
|
||||
| Readonly<{ kind: "ACTIVE"; buildId: string }>
|
||||
| Readonly<{ kind: "RELOAD_TO_ENABLE" }>
|
||||
| Readonly<{ kind: "UPDATE_WAITING" }>
|
||||
| Readonly<{ kind: "DISABLED" }>
|
||||
| Readonly<{ kind: "INCOMPATIBLE" }>
|
||||
| Readonly<{ kind: "FAILED"; code: string }>;
|
||||
|
||||
export type ServiceWorkerActivationOutcome =
|
||||
| Readonly<{ kind: "ACTIVATED_RELOAD_REQUIRED" }>
|
||||
| Readonly<{ kind: "BLOCKED_DIRTY_CLIENT" }>
|
||||
| Readonly<{ kind: "CLIENT_DRAIN_TIMEOUT" }>
|
||||
| Readonly<{ kind: "NO_WAITING_WORKER" }>
|
||||
| Readonly<{ kind: "PROTOCOL_MISMATCH" }>
|
||||
| Readonly<{ kind: "FAILED"; code: string }>;
|
||||
|
||||
export type ServiceWorkerResetOutcome =
|
||||
| Readonly<{ kind: "RESET"; cachesDeleted: number }>
|
||||
| Readonly<{ kind: "NOT_CONTROLLED" }>
|
||||
| Readonly<{ kind: "PROTOCOL_MISMATCH" }>
|
||||
| Readonly<{ kind: "FAILED"; code: string }>;
|
||||
|
||||
export type ServiceWorkerRemovalOutcome =
|
||||
| Readonly<{ kind: "ABSENT" }>
|
||||
| Readonly<{ kind: "UNREGISTERED" }>
|
||||
| Readonly<{ kind: "PURGED"; cachesDeleted: number; metadataDeleted: number }>
|
||||
| Readonly<{ kind: "OWNERSHIP_MISMATCH" }>
|
||||
| Readonly<{ kind: "FAILED"; operation: "LOOKUP" | "UNREGISTER" | "PURGE" }>;
|
||||
|
||||
export interface ServiceWorkerRuntimeHost {
|
||||
start(): Promise<ServiceWorkerStartOutcome>;
|
||||
requestActivation(): Promise<ServiceWorkerActivationOutcome>;
|
||||
resetOwnedCaches(): Promise<ServiceWorkerResetOutcome>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
const APP_NAMESPACE = "ca-frontend";
|
||||
|
||||
export type StorageBackend =
|
||||
| "memory"
|
||||
| "sessionStorage"
|
||||
| "localStorage"
|
||||
| "disabled"
|
||||
| "forbidden";
|
||||
|
||||
export type StorageValueCodec =
|
||||
| "color-scheme-v1"
|
||||
| "opaque-string-v1"
|
||||
| "none";
|
||||
|
||||
export type StorageKeyInput = Readonly<{
|
||||
logicalName: string;
|
||||
scope: string;
|
||||
name: string;
|
||||
backend: StorageBackend;
|
||||
classification:
|
||||
| "public-preference"
|
||||
| "opaque-cache"
|
||||
| "sensitive-forbidden";
|
||||
schemaVersion: number;
|
||||
valueCodec: StorageValueCodec;
|
||||
ttl: number | "session" | null;
|
||||
migration: "discard";
|
||||
quotaFallback: "memory" | "no-persist" | "feature-disable";
|
||||
}>;
|
||||
|
||||
export type StorageDefinition = Readonly<
|
||||
StorageKeyInput & { physicalKey: string }
|
||||
>;
|
||||
|
||||
export const STORAGE_REGISTRY = Object.freeze({
|
||||
COLOR_SCHEME: defineStorageKey({
|
||||
logicalName: "COLOR_SCHEME",
|
||||
scope: "preference",
|
||||
name: "color-scheme",
|
||||
backend: "localStorage",
|
||||
classification: "public-preference",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "color-scheme-v1",
|
||||
ttl: null,
|
||||
migration: "discard",
|
||||
quotaFallback: "memory",
|
||||
}),
|
||||
CHUNK_RELOAD_GUARD: defineStorageKey({
|
||||
logicalName: "CHUNK_RELOAD_GUARD",
|
||||
scope: "release",
|
||||
name: "chunk-reload-guard",
|
||||
backend: "sessionStorage",
|
||||
classification: "opaque-cache",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "opaque-string-v1",
|
||||
ttl: "session",
|
||||
migration: "discard",
|
||||
quotaFallback: "no-persist",
|
||||
}),
|
||||
QUERY_PERSISTENCE: defineStorageKey({
|
||||
logicalName: "QUERY_PERSISTENCE",
|
||||
scope: "cache",
|
||||
name: "query-persistence",
|
||||
backend: "disabled",
|
||||
classification: "sensitive-forbidden",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "none",
|
||||
ttl: null,
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
}),
|
||||
AUTH_TOKEN: defineStorageKey({
|
||||
logicalName: "AUTH_TOKEN",
|
||||
scope: "auth",
|
||||
name: "auth-token",
|
||||
backend: "forbidden",
|
||||
classification: "sensitive-forbidden",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "none",
|
||||
ttl: null,
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
}),
|
||||
});
|
||||
|
||||
export function defineStorageKey<Definition extends StorageKeyInput>(
|
||||
definition: Definition,
|
||||
): Readonly<Definition & { physicalKey: string }> {
|
||||
if (
|
||||
!["color-scheme-v1", "opaque-string-v1", "none"].includes(
|
||||
definition.valueCodec,
|
||||
)
|
||||
) {
|
||||
throw new Error("Unknown client storage value codec");
|
||||
}
|
||||
if (definition.migration !== "discard") {
|
||||
throw new Error("Unsupported client storage migration policy");
|
||||
}
|
||||
if (definition.classification === "sensitive-forbidden") {
|
||||
if (!["disabled", "forbidden"].includes(definition.backend)) {
|
||||
throw new Error("Sensitive client storage registration is forbidden");
|
||||
}
|
||||
if (definition.valueCodec !== "none") {
|
||||
throw new Error("Sensitive client storage codec is forbidden");
|
||||
}
|
||||
} else if (definition.valueCodec === "none") {
|
||||
throw new Error("Persisted storage keys require a value codec");
|
||||
}
|
||||
if (!Number.isInteger(definition.schemaVersion) || definition.schemaVersion < 1) {
|
||||
throw new Error("Storage schemaVersion must be a positive integer");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
physicalKey: buildPhysicalKey(
|
||||
definition.scope,
|
||||
definition.schemaVersion,
|
||||
definition.name,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPhysicalKey(
|
||||
scope: string,
|
||||
schemaVersion: number,
|
||||
name: string,
|
||||
): string {
|
||||
return `${APP_NAMESPACE}:${scope}:v${schemaVersion}:${name}`;
|
||||
}
|
||||
|
||||
export function getStorageDefinition(logicalName: string): StorageDefinition {
|
||||
const registry: Readonly<Record<string, StorageDefinition>> = STORAGE_REGISTRY;
|
||||
const definition = registry[logicalName];
|
||||
if (!definition) throw new Error(`Unregistered storage key: ${logicalName}`);
|
||||
if (definition.classification === "sensitive-forbidden") {
|
||||
throw new Error(`Forbidden storage key: ${logicalName}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function isStorageValueAllowed(
|
||||
definition: StorageDefinition,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
switch (definition.valueCodec) {
|
||||
case "color-scheme-v1":
|
||||
return value === "light" || value === "dark" || value === "system";
|
||||
case "opaque-string-v1":
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length >= 1 &&
|
||||
value.length <= 2_048
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
export const TELEMETRY_ATTRIBUTE_ALLOWLIST = Object.freeze([
|
||||
"app_version",
|
||||
"build_id",
|
||||
"release_id",
|
||||
"config_schema_version",
|
||||
"api_contract_version",
|
||||
"route_id",
|
||||
"operation_id",
|
||||
"error_kind",
|
||||
"http_status_group",
|
||||
"attempt_count_bucket",
|
||||
"duration_bucket",
|
||||
"component_boundary",
|
||||
"active_release_id",
|
||||
"mismatch_kind",
|
||||
"reason",
|
||||
"queue_size_bucket",
|
||||
] as const);
|
||||
|
||||
export const TELEMETRY_FORBIDDEN_ATTRIBUTES = Object.freeze([
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"authorization_header",
|
||||
"cookie",
|
||||
"email",
|
||||
"user_name",
|
||||
"raw_user_id",
|
||||
"raw_url",
|
||||
"query_string",
|
||||
"request_body",
|
||||
"response_body",
|
||||
"storage_value",
|
||||
"stack_in_user_message",
|
||||
] as const);
|
||||
|
||||
type TelemetryDefinitionFor<Name extends string> = Readonly<{
|
||||
eventName: Name;
|
||||
trigger: string;
|
||||
requiredAttributes: readonly string[];
|
||||
optionalAttributes: readonly string[];
|
||||
forbiddenAttributes: readonly string[];
|
||||
sampling: string;
|
||||
delivery: "best-effort";
|
||||
}>;
|
||||
|
||||
const event = <Name extends string>(
|
||||
eventName: Name,
|
||||
trigger: string,
|
||||
requiredAttributes: readonly string[],
|
||||
optionalAttributes: readonly string[] = [],
|
||||
sampling = "all",
|
||||
): TelemetryDefinitionFor<Name> =>
|
||||
Object.freeze({
|
||||
eventName,
|
||||
trigger,
|
||||
requiredAttributes: Object.freeze(requiredAttributes),
|
||||
optionalAttributes: Object.freeze(optionalAttributes),
|
||||
forbiddenAttributes: TELEMETRY_FORBIDDEN_ATTRIBUTES,
|
||||
sampling,
|
||||
delivery: "best-effort",
|
||||
});
|
||||
|
||||
export const TELEMETRY_REGISTRY = Object.freeze({
|
||||
"app.boot.failed": event("app.boot.failed", "boot validation failure", [
|
||||
"error_kind",
|
||||
"build_id",
|
||||
"config_schema_version",
|
||||
]),
|
||||
"api.request.failed": event("api.request.failed", "terminal API failure", [
|
||||
"error_kind",
|
||||
"http_status_group",
|
||||
"attempt_count_bucket",
|
||||
"route_id",
|
||||
], ["operation_id", "duration_bucket"]),
|
||||
"ui.render.failed": event("ui.render.failed", "React boundary catch", [
|
||||
"route_id",
|
||||
"build_id",
|
||||
"component_boundary",
|
||||
]),
|
||||
"release.mismatch.detected": event(
|
||||
"release.mismatch.detected",
|
||||
"release tuple mismatch",
|
||||
["build_id", "active_release_id", "mismatch_kind"],
|
||||
),
|
||||
"telemetry.delivery.dropped": event(
|
||||
"telemetry.delivery.dropped",
|
||||
"queue or sink failure",
|
||||
["reason", "queue_size_bucket"],
|
||||
[],
|
||||
"internal-counter",
|
||||
),
|
||||
});
|
||||
|
||||
export type TelemetryEventName = keyof typeof TELEMETRY_REGISTRY;
|
||||
export type TelemetryDefinition = TelemetryDefinitionFor<TelemetryEventName>;
|
||||
export type TelemetryEvent = Readonly<{
|
||||
eventName: TelemetryEventName;
|
||||
timestamp: string;
|
||||
attributes: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export type TelemetryProjectionResult =
|
||||
| Readonly<{ success: true; event: TelemetryEvent }>
|
||||
| Readonly<{ success: false; reason: string }>;
|
||||
|
||||
const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/;
|
||||
const ATTRIBUTE_VALUE_POLICIES: Readonly<
|
||||
Record<string, (value: string) => boolean>
|
||||
> = Object.freeze({
|
||||
route_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
|
||||
operation_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
|
||||
error_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
|
||||
http_status_group: (value) => /^(?:[1-5]xx|none)$/.test(value),
|
||||
attempt_count_bucket: (value) => /^(?:1|2|3|3-4|5\+)$/.test(value),
|
||||
duration_bucket: (value) =>
|
||||
/^(?:lt100ms|100-499ms|500-1999ms|gte2000ms|unknown)$/.test(
|
||||
value,
|
||||
),
|
||||
component_boundary: (value) =>
|
||||
/^(?:route|feature|boot)$/.test(value),
|
||||
mismatch_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
|
||||
reason: (value) =>
|
||||
/^(?:queue-full|sink-failure|invalid-event|invalid-context|serialization-failure)$/.test(
|
||||
value,
|
||||
),
|
||||
queue_size_bucket: (value) =>
|
||||
/^(?:0|1-10|11-50|51\+)$/.test(value),
|
||||
});
|
||||
|
||||
function validAttributeValue(key: string, value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const policy = ATTRIBUTE_VALUE_POLICIES[key];
|
||||
return policy ? policy(value) : SAFE_IDENTIFIER.test(value);
|
||||
}
|
||||
|
||||
function isTelemetryEventName(value: string): value is TelemetryEventName {
|
||||
return Object.hasOwn(TELEMETRY_REGISTRY, value);
|
||||
}
|
||||
|
||||
function includesAttribute(list: readonly string[], key: string): boolean {
|
||||
return list.includes(key);
|
||||
}
|
||||
|
||||
function projectTelemetryEventUnsafe(
|
||||
eventName: string,
|
||||
attributes: Readonly<Record<string, unknown>>,
|
||||
now: () => number = Date.now,
|
||||
): TelemetryProjectionResult {
|
||||
if (!isTelemetryEventName(eventName)) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "unregistered-event",
|
||||
};
|
||||
}
|
||||
const definition = TELEMETRY_REGISTRY[eventName];
|
||||
|
||||
const attributeKeys = Object.keys(attributes);
|
||||
if (
|
||||
attributeKeys.length >
|
||||
TELEMETRY_ATTRIBUTE_ALLOWLIST.length +
|
||||
TELEMETRY_FORBIDDEN_ATTRIBUTES.length
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "invalid-attribute-value",
|
||||
};
|
||||
}
|
||||
const unknown = attributeKeys.filter(
|
||||
(key) =>
|
||||
!includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) &&
|
||||
!includesAttribute(TELEMETRY_FORBIDDEN_ATTRIBUTES, key),
|
||||
);
|
||||
if (unknown.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "unknown-attributes",
|
||||
};
|
||||
}
|
||||
const projected = Object.fromEntries(
|
||||
Object.entries(attributes).filter(
|
||||
([key, value]) =>
|
||||
includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) &&
|
||||
!includesAttribute(TELEMETRY_FORBIDDEN_ATTRIBUTES, key) &&
|
||||
validAttributeValue(key, value),
|
||||
),
|
||||
);
|
||||
const invalid = Object.entries(attributes).filter(
|
||||
([key, value]) =>
|
||||
includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) &&
|
||||
!validAttributeValue(key, value),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "invalid-attribute-value",
|
||||
};
|
||||
}
|
||||
const missing = definition.requiredAttributes.filter(
|
||||
(key) => projected[key] === undefined,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "missing-required-attributes",
|
||||
};
|
||||
}
|
||||
|
||||
let timestamp: string;
|
||||
try {
|
||||
timestamp = new Date(now()).toISOString();
|
||||
} catch {
|
||||
timestamp = new Date(0).toISOString();
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
event: Object.freeze({
|
||||
eventName,
|
||||
timestamp,
|
||||
attributes: Object.freeze(projected),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectTelemetryEvent(
|
||||
eventName: string,
|
||||
attributes: Readonly<Record<string, unknown>>,
|
||||
now: () => number = Date.now,
|
||||
): TelemetryProjectionResult {
|
||||
try {
|
||||
return projectTelemetryEventUnsafe(eventName, attributes, now);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
reason: "serialization-failure",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
export const WEB_PUSH_LIMITS = Object.freeze({
|
||||
decodedHintBytes: 3 * 1024,
|
||||
hintFutureSkewMs: 5 * 60 * 1_000,
|
||||
hintMaxLifetimeMs: 24 * 60 * 60 * 1_000,
|
||||
handlerDeadlineMs: 10_000,
|
||||
fenceOperationDeadlineMs: 2_000,
|
||||
nativeOperationDeadlineMs: 30_000,
|
||||
backendOperationDeadlineMs: 15_000,
|
||||
notificationCleanupCount: 64,
|
||||
notificationCleanupDeadlineMs: 2_000,
|
||||
clientHandoffCount: 32,
|
||||
} as const);
|
||||
|
||||
export const WEB_PUSH_PROTOCOLS = Object.freeze({
|
||||
control: "PUSH_CONTROL_V1",
|
||||
hint: "WEB_PUSH_HINT_V1",
|
||||
click: "NOTIFICATION_CLICK_DATA_V1",
|
||||
registration: "WEB_PUSH_REGISTRATION_V1",
|
||||
reconciliation: "WEB_PUSH_RECONCILIATION_V1",
|
||||
revoke: "WEB_PUSH_REVOKE_V1",
|
||||
clickHandoff: "WEB_PUSH_CLICK_HANDOFF_V1",
|
||||
reconcileRequired: "WEB_PUSH_RECONCILE_REQUIRED_V1",
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* Product IDs stay opaque to the mechanism. The selected composition must
|
||||
* provide a closed registry that resolves these syntactically validated IDs.
|
||||
*/
|
||||
export type NotificationTypeId = string;
|
||||
export type NotificationRouteIntentId = string;
|
||||
|
||||
export type PushAuthoritySnapshot = Readonly<{
|
||||
fenceGeneration: string;
|
||||
sessionBindingEpoch: string;
|
||||
releaseEpoch: string;
|
||||
}>;
|
||||
|
||||
export type PushControlAssociationV1 =
|
||||
| Readonly<{ state: "UNASSOCIATED" }>
|
||||
| Readonly<{
|
||||
state: "ACTIVE" | "REVOKED";
|
||||
associationEpoch: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* One origin-scoped durable authority record shared by the window and worker.
|
||||
* It intentionally contains no account identifier or native subscription
|
||||
* material. `updatedAt` is diagnostic metadata, never an ordering authority.
|
||||
*/
|
||||
export type PushControlV1 = Readonly<{
|
||||
protocol: typeof WEB_PUSH_PROTOCOLS.control;
|
||||
fenceGeneration: string;
|
||||
sessionBindingEpoch: string;
|
||||
releaseEpoch: string;
|
||||
updatedAt: string;
|
||||
association: PushControlAssociationV1;
|
||||
}>;
|
||||
|
||||
export type WebPushHintV1 = Readonly<{
|
||||
protocol: typeof WEB_PUSH_PROTOCOLS.hint;
|
||||
notificationType: NotificationTypeId;
|
||||
notificationId: string;
|
||||
associationEpoch: string;
|
||||
releaseEpoch: string;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
routeIntent: NotificationRouteIntentId;
|
||||
}>;
|
||||
|
||||
export type NotificationClickDataV1 = Readonly<{
|
||||
protocol: typeof WEB_PUSH_PROTOCOLS.click;
|
||||
notificationId: string;
|
||||
routeIntent: NotificationRouteIntentId;
|
||||
associationEpoch: string;
|
||||
releaseEpoch: string;
|
||||
expiresAt: string;
|
||||
}>;
|
||||
|
||||
export type WebPushReadinessState =
|
||||
| "PUSH_READY"
|
||||
| "PUSH_PERMISSION_REQUIRED"
|
||||
| "PUSH_DENIED"
|
||||
| "PUSH_UNSUPPORTED"
|
||||
| "PUSH_UNAVAILABLE";
|
||||
|
||||
export type WebPushUnavailableReason =
|
||||
| "ABORTED"
|
||||
| "BACKEND_ASSOCIATION_MISSING"
|
||||
| "BACKEND_REVOKE_AMBIGUOUS"
|
||||
| "BUSY"
|
||||
| "CLOSED"
|
||||
| "LOCAL_FENCE_UNSAFE"
|
||||
| "NATIVE_UNSUBSCRIBE_AMBIGUOUS"
|
||||
| "NATIVE_SUBSCRIPTION_MISSING"
|
||||
| "PERMISSION_DISMISSED"
|
||||
| "REGISTRATION_NOT_ACTIVE"
|
||||
| "REVOKED"
|
||||
| "SESSION_AUTHORITY_CHANGED"
|
||||
| "SUBSCRIPTION_KEY_MISMATCH"
|
||||
| "WORKER_UNAVAILABLE";
|
||||
|
||||
export type WebPushReadiness = Readonly<{
|
||||
state: WebPushReadinessState;
|
||||
reason?: WebPushUnavailableReason;
|
||||
}>;
|
||||
|
||||
export type WebPushOperation =
|
||||
| "CONTROL_OPEN"
|
||||
| "CONTROL_READ"
|
||||
| "CONTROL_PREPARE"
|
||||
| "CONTROL_ACTIVATE"
|
||||
| "CONTROL_REVOKE"
|
||||
| "CONTROL_PURGE"
|
||||
| "PERMISSION_REQUEST"
|
||||
| "SUBSCRIPTION_INSPECT"
|
||||
| "SUBSCRIPTION_CREATE"
|
||||
| "SUBSCRIPTION_RECONCILE"
|
||||
| "SUBSCRIPTION_REVOKE"
|
||||
| "PUSH_DECODE"
|
||||
| "PUSH_HANDLE"
|
||||
| "NOTIFICATION_SHOW"
|
||||
| "NOTIFICATION_CLICK"
|
||||
| "NOTIFICATION_CLEANUP";
|
||||
|
||||
export type WebPushFailureCode =
|
||||
| "ABORTED"
|
||||
| "ASSOCIATION_MISMATCH"
|
||||
| "BLOCKED"
|
||||
| "CONTRACT_REJECTED"
|
||||
| "CONTROL_CORRUPT"
|
||||
| "DEADLINE_EXCEEDED"
|
||||
| "DECLARATIVE_PUSH_FORBIDDEN"
|
||||
| "EXPIRED"
|
||||
| "INVALID_INPUT"
|
||||
| "LIMIT_EXCEEDED"
|
||||
| "NATIVE_FAILURE"
|
||||
| "PERMISSION_DENIED"
|
||||
| "PROVIDER_UNAVAILABLE"
|
||||
| "RELEASE_MISMATCH"
|
||||
| "STALE_AUTHORITY"
|
||||
| "STALE_REVISION"
|
||||
| "TOMBSTONE_CONFLICT"
|
||||
| "UNSUPPORTED";
|
||||
|
||||
export type WebPushFailure = Readonly<{
|
||||
code: WebPushFailureCode;
|
||||
operation: WebPushOperation;
|
||||
retryable: boolean;
|
||||
}>;
|
||||
|
||||
export type WebPushResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; error: WebPushFailure }>;
|
||||
|
||||
export type WebPushObservationEvent =
|
||||
| "web_push_permission_finished"
|
||||
| "web_push_registration_finished"
|
||||
| "web_push_subscription_rotated"
|
||||
| "web_push_hint_processed"
|
||||
| "web_push_notification_finished"
|
||||
| "web_push_click_dispatched"
|
||||
| "web_push_association_revoked";
|
||||
|
||||
export type WebPushObservation = Readonly<{
|
||||
event: WebPushObservationEvent;
|
||||
outcome: "SUCCEEDED" | "FAILED" | "DEGRADED";
|
||||
reason?: WebPushFailureCode | WebPushUnavailableReason;
|
||||
}>;
|
||||
|
||||
export interface WebPushObserver {
|
||||
record(observation: WebPushObservation): void;
|
||||
}
|
||||
|
||||
export function webPushSuccess<Value>(
|
||||
value: Value,
|
||||
): WebPushResult<Value> {
|
||||
return Object.freeze({ ok: true, value });
|
||||
}
|
||||
|
||||
export function webPushFailure(
|
||||
code: WebPushFailureCode,
|
||||
operation: WebPushOperation,
|
||||
retryable = false,
|
||||
): WebPushResult<never> {
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
error: Object.freeze({ code, operation, retryable }),
|
||||
});
|
||||
}
|
||||
|
||||
export function samePushAuthority(
|
||||
left: PushAuthoritySnapshot,
|
||||
right: PushAuthoritySnapshot,
|
||||
): boolean {
|
||||
return (
|
||||
left.fenceGeneration === right.fenceGeneration &&
|
||||
left.sessionBindingEpoch === right.sessionBindingEpoch &&
|
||||
left.releaseEpoch === right.releaseEpoch
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* §16. Generic CPU Web Worker contract.
|
||||
*
|
||||
* The capability is `NOT_SELECTED` by default (§16.2). These types exist so a
|
||||
* selection can be expressed and validated, but no production worker entry is
|
||||
* created until a measured, CPU-bound task with an owner is contributed. This
|
||||
* runtime is separate from the OPFS dedicated worker and the Service Worker.
|
||||
*/
|
||||
|
||||
export const WEB_WORKER_PROTOCOL = "CA_WEB_WORKER_V1" as const;
|
||||
|
||||
export const WEB_WORKER_BOUNDS = Object.freeze({
|
||||
taskGroups: 16,
|
||||
tasksPerGroup: 32,
|
||||
activeGroups: 4,
|
||||
queuedPerGroup: 32,
|
||||
queuedBytesPerGroup: 16 * 1024 * 1024,
|
||||
defaultInputBytes: 1024 * 1024,
|
||||
hardInputBytes: 8 * 1024 * 1024,
|
||||
defaultOutputBytes: 1024 * 1024,
|
||||
hardOutputBytes: 8 * 1024 * 1024,
|
||||
defaultDeadlineMs: 5_000,
|
||||
hardDeadlineMs: 30_000,
|
||||
cancelGraceMs: 250,
|
||||
idleTerminateMs: 60_000,
|
||||
restartsPerWindow: 3,
|
||||
restartWindowMs: 300_000,
|
||||
transferables: 16,
|
||||
mainThreadChunkBudgetMs: 8,
|
||||
mainThreadFallbackInputBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
export interface InstalledWorkerTask {
|
||||
readonly taskId: string;
|
||||
readonly taskVersion: 1;
|
||||
readonly maximumInputBytes: number;
|
||||
readonly maximumOutputBytes: number;
|
||||
readonly deadlineMs: number;
|
||||
}
|
||||
|
||||
export interface InstalledWebWorkerContribution {
|
||||
readonly taskGroupId: string;
|
||||
readonly tasks: readonly InstalledWorkerTask[];
|
||||
readonly fallback: "MAIN_THREAD_CHUNKED" | "UNSUPPORTED";
|
||||
}
|
||||
|
||||
export type WorkerRequestMessage = Readonly<{
|
||||
protocol: typeof WEB_WORKER_PROTOCOL;
|
||||
kind: "EXECUTE";
|
||||
taskId: string;
|
||||
taskVersion: 1;
|
||||
requestId: string;
|
||||
workerGeneration: number;
|
||||
deadlineEpochMs: number;
|
||||
payload: unknown;
|
||||
}>;
|
||||
|
||||
export type WorkerCancelMessage = Readonly<{
|
||||
protocol: typeof WEB_WORKER_PROTOCOL;
|
||||
kind: "CANCEL";
|
||||
requestId: string;
|
||||
workerGeneration: number;
|
||||
}>;
|
||||
|
||||
export type WorkerFailureCode =
|
||||
| "TASK_UNKNOWN"
|
||||
| "VERSION_UNSUPPORTED"
|
||||
| "INPUT_INVALID"
|
||||
| "INPUT_TOO_LARGE"
|
||||
| "OUTPUT_INVALID"
|
||||
| "OUTPUT_TOO_LARGE"
|
||||
| "QUEUE_FULL"
|
||||
| "DEADLINE_EXCEEDED"
|
||||
| "CANCELLED"
|
||||
| "CRASHED"
|
||||
| "TRANSFER_FAILED"
|
||||
| "STALE_RESULT"
|
||||
| "RUNTIME_PROTOCOL_FAILURE";
|
||||
|
||||
export type WorkerResponseMessage =
|
||||
| Readonly<{
|
||||
protocol: typeof WEB_WORKER_PROTOCOL;
|
||||
kind: "SUCCESS";
|
||||
requestId: string;
|
||||
workerGeneration: number;
|
||||
payload: unknown;
|
||||
}>
|
||||
| Readonly<{
|
||||
protocol: typeof WEB_WORKER_PROTOCOL;
|
||||
kind: "FAILURE";
|
||||
requestId: string;
|
||||
workerGeneration: number;
|
||||
code: WorkerFailureCode;
|
||||
}>;
|
||||
|
||||
export function validateWebWorkerContributions(
|
||||
contributions: readonly InstalledWebWorkerContribution[],
|
||||
): readonly InstalledWebWorkerContribution[] {
|
||||
const bounds = WEB_WORKER_BOUNDS;
|
||||
if (contributions.length > bounds.taskGroups) {
|
||||
throw new TypeError("Web Worker task groups exceed their bound.");
|
||||
}
|
||||
const groupIds = new Set<string>();
|
||||
const taskIds = new Set<string>();
|
||||
for (const contribution of contributions) {
|
||||
if (!contribution.taskGroupId || groupIds.has(contribution.taskGroupId)) {
|
||||
throw new TypeError("Web Worker task group identity is invalid.");
|
||||
}
|
||||
groupIds.add(contribution.taskGroupId);
|
||||
if (
|
||||
contribution.tasks.length === 0 ||
|
||||
contribution.tasks.length > bounds.tasksPerGroup
|
||||
) {
|
||||
throw new TypeError("Web Worker task count is out of range.");
|
||||
}
|
||||
for (const task of contribution.tasks) {
|
||||
const qualified = `${contribution.taskGroupId}/${task.taskId}`;
|
||||
if (!task.taskId || taskIds.has(qualified)) {
|
||||
throw new TypeError("Duplicate Web Worker task.");
|
||||
}
|
||||
taskIds.add(qualified);
|
||||
if (
|
||||
task.taskVersion !== 1 ||
|
||||
!Number.isSafeInteger(task.maximumInputBytes) ||
|
||||
task.maximumInputBytes < 1 ||
|
||||
task.maximumInputBytes > bounds.hardInputBytes ||
|
||||
!Number.isSafeInteger(task.maximumOutputBytes) ||
|
||||
task.maximumOutputBytes < 1 ||
|
||||
task.maximumOutputBytes > bounds.hardOutputBytes ||
|
||||
!Number.isSafeInteger(task.deadlineMs) ||
|
||||
task.deadlineMs < 1 ||
|
||||
task.deadlineMs > bounds.hardDeadlineMs
|
||||
) {
|
||||
throw new TypeError(`Web Worker task bounds invalid: ${qualified}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze([...contributions]);
|
||||
}
|
||||
Reference in New Issue
Block a user