refactor: 리펙토링

This commit is contained in:
DongHyeonka
2026-08-01 19:39:59 +09:00
parent 9c959ea2a5
commit c6da03369c
171 changed files with 20329 additions and 782 deletions
@@ -41,6 +41,9 @@ const STORAGE_PULSE_KEY =
export function createBrowserCrossContextInvalidationFromHost(
dependencies: BrowserCrossContextHostDependencies,
): BrowserCrossContextInvalidation | undefined {
// A zero-feature build owns no cross-context invalidation runtime. Preserve
// that property strictly: do not even probe browser capability getters.
if (dependencies.topics.length === 0) return undefined;
const host =
dependencies.host ??
(globalThis as unknown as Record<string, unknown>);
+160
View File
@@ -0,0 +1,160 @@
/**
* §7.9–§7.10. Common bounded body reader.
*
* `bounded-json.ts` stays the V2 reader for already-migrated callers; this
* module adds the byte-level primitives execution V3 needs: a raw bounded read,
* a strict media-type check and the `NONE` one-byte probe.
*/
export type BoundedBytesOutcome =
| Readonly<{ ok: true; bytes: Uint8Array }>
| Readonly<{ ok: false; code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE" }>;
export type BodyProbeOutcome =
| Readonly<{ ok: true; present: boolean }>
| Readonly<{ ok: false; code: "RESPONSE_STREAM_FAILURE" }>;
/** Essence match: `application/json` or any `*+json` subtype. */
export function isJsonMediaType(headerValue: string | null): boolean {
if (!headerValue) return false;
const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? "";
return essence === "application/json" || essence.endsWith("+json");
}
export function declaredContentLength(response: Response): number | null {
const raw = response.headers.get("content-length");
if (raw === null) return null;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : null;
}
export async function readBoundedBytes(
response: Response,
maximumBytes: number,
): Promise<BoundedBytesOutcome> {
const declared = declaredContentLength(response);
if (declared !== null && declared > maximumBytes) {
await cancelBody(response);
return failure("RESPONSE_TOO_LARGE");
}
if (!response.body) {
return Object.freeze({ ok: true as const, bytes: new Uint8Array(0) });
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
if (!next.value) continue;
total += next.value.byteLength;
if (total > maximumBytes) {
await reader.cancel().catch(() => {});
return failure("RESPONSE_TOO_LARGE");
}
chunks.push(next.value);
}
} catch {
await reader.cancel().catch(() => {});
return failure("RESPONSE_STREAM_FAILURE");
} finally {
try {
reader.releaseLock();
} catch {
// A cancelled reader has already released its lock.
}
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return Object.freeze({ ok: true as const, bytes });
}
/**
* §7.10 `NONE`. A declared positive length is an immediate violation. Otherwise
* at most one byte is probed: the descriptor does not permit an unexpected
* body, so the runtime never drains an arbitrary amount to find out.
*/
export async function probeForbiddenBody(
response: Response,
): Promise<BodyProbeOutcome> {
const declared = declaredContentLength(response);
if (declared !== null && declared > 0) {
await cancelBody(response);
return Object.freeze({ ok: true as const, present: true });
}
if (!response.body) {
return Object.freeze({ ok: true as const, present: false });
}
const reader = response.body.getReader();
try {
const next = await reader.read();
if (next.done || !next.value || next.value.byteLength === 0) {
return Object.freeze({ ok: true as const, present: false });
}
await reader.cancel().catch(() => {});
return Object.freeze({ ok: true as const, present: true });
} catch {
await reader.cancel().catch(() => {});
return Object.freeze({
ok: false as const,
code: "RESPONSE_STREAM_FAILURE" as const,
});
} finally {
try {
reader.releaseLock();
} catch {
// Already released by cancel().
}
}
}
export type DecodedJson =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; code: "UTF8_INVALID" | "JSON_INVALID" }>;
export function decodeJsonBytes(bytes: Uint8Array): DecodedJson {
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return Object.freeze({ ok: false as const, code: "UTF8_INVALID" as const });
}
try {
return Object.freeze({ ok: true as const, value: JSON.parse(text) });
} catch {
return Object.freeze({ ok: false as const, code: "JSON_INVALID" as const });
}
}
export function isEffectivelyEmpty(bytes: Uint8Array): boolean {
if (bytes.byteLength === 0) return true;
for (const byte of bytes) {
// Space, tab, LF, CR are the only permitted "empty" filler.
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
return false;
}
}
return true;
}
async function cancelBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// Cancelling an already-settled body is not itself a failure.
}
}
function failure(
code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE",
): BoundedBytesOutcome {
return Object.freeze({ ok: false as const, code });
}
+288
View File
@@ -0,0 +1,288 @@
import {
HTTP_EXECUTION_CEILINGS,
type InstalledHttpContract,
} from "../../contracts/external-contract-runtime.ts";
/**
* §7.4–§7.7. Descriptor-driven request projection.
*
* Nothing here re-derives operation semantics. The package descriptor supplies
* path values, query entry order and the body value; this module only encodes,
* bounds and re-verifies them.
*/
export type CredentialPatchOutcome =
| Readonly<{
kind: "READY";
headers: Readonly<Record<string, string>>;
credentials: RequestCredentials;
}>
| Readonly<{ kind: "UNAUTHENTICATED" }>
| Readonly<{ kind: "UNAVAILABLE" }>
| Readonly<{ kind: "SCOPE_FENCED" }>;
/** §7.7. The complete set of headers a credential bridge may contribute. */
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
"authorization",
"x-csrf-token",
"x-tenant-context",
]);
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
"host",
"origin",
"referer",
"cookie",
"content-length",
"connection",
"transfer-encoding",
"upgrade",
]);
export type RequestProjectionFailure =
| "PROJECTION_RUNTIME_FAILURE"
| "PROJECTION_INVALID"
| "PATH_PLACEHOLDER_MISSING"
| "PATH_VALUE_INVALID"
| "QUERY_TOO_LARGE"
| "URL_TOO_LARGE"
| "URL_ORIGIN_ESCAPED"
| "REQUEST_BODY_TOO_LARGE"
| "REQUEST_BODY_UNENCODABLE"
| "REQUEST_BODY_UNEXPECTED";
export type ProjectedRequest = Readonly<{
url: string;
method: string;
bodyBytes: Uint8Array | null;
}>;
export type RequestProjectionOutcome =
| Readonly<{ ok: true; request: ProjectedRequest }>
| Readonly<{ ok: false; failure: RequestProjectionFailure }>;
const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9_]*)\}|:([A-Za-z][A-Za-z0-9_]*)/g;
const encoder = new TextEncoder();
/**
* §7.5. Built from the validated base URL and the descriptor's named values.
* Segments are encoded exactly once; a raw slash inside a value is rejected
* rather than silently creating a new path segment.
*/
export function projectRequest<Input, WireOutput, Problem>(
installed: InstalledHttpContract<Input, WireOutput, Problem>,
input: Input,
baseUrl: string,
): RequestProjectionOutcome {
try {
return projectRequestChecked(installed, input, baseUrl);
} catch {
return failure("PROJECTION_RUNTIME_FAILURE");
}
}
function projectRequestChecked<Input, WireOutput, Problem>(
installed: InstalledHttpContract<Input, WireOutput, Problem>,
input: Input,
baseUrl: string,
): RequestProjectionOutcome {
const contract = installed.contract;
const projection = contract.projectRequest(input);
if (!isValidProjection(projection)) return failure("PROJECTION_INVALID");
let missing = false;
let invalid = false;
const path = contract.pathTemplate.replace(
PLACEHOLDER,
(_match, braced?: string, colon?: string) => {
const name = braced ?? colon ?? "";
const value = projection.pathValues[name];
if (typeof value !== "string" || value.length === 0) {
missing = true;
return "";
}
if (value.includes("/") || value.includes("\\")) {
invalid = true;
return "";
}
return encodeURIComponent(value);
},
);
if (missing) return failure("PATH_PLACEHOLDER_MISSING");
if (invalid) return failure("PATH_VALUE_INVALID");
const base = new URL(baseUrl);
const url = new URL(path.replace(/^\/+/, ""), base);
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
return failure("URL_ORIGIN_ESCAPED");
}
const search = new URLSearchParams();
for (const [key, value] of projection.queryEntries) {
search.append(key, value);
}
const encodedQuery = search.toString();
if (
encoder.encode(encodedQuery).byteLength >
HTTP_EXECUTION_CEILINGS.encodedQueryBytes
) {
return failure("QUERY_TOO_LARGE");
}
url.search = encodedQuery;
if (encoder.encode(url.href).byteLength > HTTP_EXECUTION_CEILINGS.finalUrlBytes) {
return failure("URL_TOO_LARGE");
}
let bodyBytes: Uint8Array | null = null;
if (contract.requestBody === "JSON") {
let encoded: string;
try {
encoded = JSON.stringify(projection.body);
} catch {
return failure("REQUEST_BODY_UNENCODABLE");
}
if (typeof encoded !== "string") {
return failure("REQUEST_BODY_UNENCODABLE");
}
bodyBytes = encoder.encode(encoded);
if (bodyBytes.byteLength > installed.frontend.requestByteLimit) {
return failure("REQUEST_BODY_TOO_LARGE");
}
} else if (projection.body !== null && projection.body !== undefined) {
return failure("REQUEST_BODY_UNEXPECTED");
}
return Object.freeze({
ok: true as const,
request: Object.freeze({
url: url.href,
method: contract.method,
bodyBytes,
}),
});
}
function isValidProjection(
value: unknown,
): value is ReturnType<
InstalledHttpContract<unknown, unknown, unknown>["contract"]["projectRequest"]
> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Record<string, unknown>;
const keys = Object.keys(candidate).sort();
if (keys.join("|") !== "body|pathValues|queryEntries") return false;
const pathValues = candidate.pathValues;
if (!pathValues || typeof pathValues !== "object" || Array.isArray(pathValues)) {
return false;
}
const pathPrototype = Object.getPrototypeOf(pathValues);
if (pathPrototype !== Object.prototype && pathPrototype !== null) return false;
const pathEntries = Object.entries(pathValues as Record<string, unknown>);
if (pathEntries.length > 32) return false;
for (const [key, pathValue] of pathEntries) {
if (
!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) ||
typeof pathValue !== "string" ||
pathValue.length === 0 ||
encoder.encode(pathValue).byteLength > HTTP_EXECUTION_CEILINGS.pathTemplateBytes
) {
return false;
}
}
const queryEntries = candidate.queryEntries;
if (!Array.isArray(queryEntries) || queryEntries.length > 256) return false;
for (const entry of queryEntries) {
if (
!Array.isArray(entry) ||
entry.length !== 2 ||
typeof entry[0] !== "string" ||
typeof entry[1] !== "string"
) {
return false;
}
}
return true;
}
export type FinalInvariantInput = Readonly<{
request: ProjectedRequest;
expectedMethod: string;
baseUrl: string;
init: RequestInit;
headers: Readonly<Record<string, string>>;
requestByteLimit: number;
deadlineRemainingMs: number;
scopeIsCurrent: boolean;
}>;
export type FinalInvariantFailure =
| "METHOD_CHANGED"
| "URL_NOT_ALLOWED"
| "REDIRECT_MODE_INVALID"
| "CREDENTIALS_MODE_INVALID"
| "HEADER_NOT_ALLOWED"
| "FORBIDDEN_HEADER"
| "REQUEST_BODY_TOO_LARGE"
| "DEADLINE_EXPIRED"
| "SCOPE_FENCED";
/**
* §7.4. Runs after the credential patch and immediately before dispatch. A
* failure here means `fetch()` is called zero times, so a defective auth
* adapter can never alter the method, target or transport policy.
*/
export function checkFinalInvariants(
input: FinalInvariantInput,
): FinalInvariantFailure | null {
if (input.request.method !== input.expectedMethod) return "METHOD_CHANGED";
const base = new URL(input.baseUrl);
let target: URL;
try {
target = new URL(input.request.url);
} catch {
return "URL_NOT_ALLOWED";
}
if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname)) {
return "URL_NOT_ALLOWED";
}
if (input.init.redirect !== "error") return "REDIRECT_MODE_INVALID";
if (
input.init.credentials !== "omit" &&
input.init.credentials !== "same-origin" &&
input.init.credentials !== "include"
) {
return "CREDENTIALS_MODE_INVALID";
}
for (const name of Object.keys(input.headers)) {
const lower = name.toLowerCase();
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
if (
lower !== "accept" &&
lower !== "content-type" &&
lower !== "idempotency-key" &&
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
) {
return "HEADER_NOT_ALLOWED";
}
}
if (
input.request.bodyBytes &&
input.request.bodyBytes.byteLength > input.requestByteLimit
) {
return "REQUEST_BODY_TOO_LARGE";
}
if (input.deadlineRemainingMs <= 0) return "DEADLINE_EXPIRED";
if (!input.scopeIsCurrent) return "SCOPE_FENCED";
return null;
}
function failure(failureKind: RequestProjectionFailure): RequestProjectionOutcome {
return Object.freeze({ ok: false as const, failure: failureKind });
}
+151
View File
@@ -0,0 +1,151 @@
import type {
CommandEffectDescriptor,
CommandEffectClassification,
} from "../../contracts/external-contract-runtime.ts";
/**
* §8.7–§8.9. Mutation effect certainty.
*
* The frontend never infers "not applied" from an HTTP status alone. Anything
* observed after the request was dispatched but before a classified terminal
* response is `MAYBE_APPLIED`, which forbids automatic resend.
*/
export type MutationEffectCertainty =
| "NOT_STARTED"
| "NOT_APPLIED"
| "MAYBE_APPLIED"
| "APPLIED_CONFIRMED";
export type PhysicalAttemptState =
| "NOT_STARTED"
| "PREPARING"
| "READY_TO_SEND"
| "DISPATCHED"
| "RESPONSE_HEADERS"
| "READING_BODY"
| "VALIDATING"
| "MAPPING_READY"
| "SETTLED";
/**
* `READY_TO_SEND` is recorded immediately before entering the `fetch()`
* invocation expression and `DISPATCHED` immediately after the promise is
* returned. A synchronous throw therefore leaves the attempt `NOT_STARTED`.
*/
export function certaintyForAbandonedAttempt(
state: PhysicalAttemptState,
isCommand: boolean,
): MutationEffectCertainty {
if (!isCommand) return "NOT_STARTED";
switch (state) {
case "NOT_STARTED":
case "PREPARING":
case "READY_TO_SEND":
return "NOT_STARTED";
default:
return "MAYBE_APPLIED";
}
}
export type ProblemEffectInput<Problem> = Readonly<{
status: number;
problem: Problem;
descriptor: CommandEffectDescriptor<Problem> | null;
}>;
export type ProblemEffectOutcome = Readonly<{
effect: MutationEffectCertainty;
contractRuntimeFailure: boolean;
}>;
const CLASSIFICATIONS: ReadonlySet<CommandEffectClassification> = new Set([
"NOT_APPLIED",
"APPLIED_CONFIRMED",
"MAYBE_APPLIED",
]);
/**
* §4.4. The classifier is package-owned and pure. A throw or an unrecognised
* return value fails safe to `MAYBE_APPLIED` and is recorded as a contract
* runtime failure rather than being silently treated as "not applied".
*/
export function classifyProblemEffect<Problem>(
input: ProblemEffectInput<Problem>,
): ProblemEffectOutcome {
if (!input.descriptor) {
// A read operation carries no command effect; there is nothing to apply.
return Object.freeze({
effect: "NOT_STARTED" as const,
contractRuntimeFailure: false,
});
}
let classification: CommandEffectClassification;
try {
classification = input.descriptor.classifyProblem({
status: input.status,
problem: input.problem,
});
} catch {
return Object.freeze({
effect: "MAYBE_APPLIED" as const,
contractRuntimeFailure: true,
});
}
if (!CLASSIFICATIONS.has(classification)) {
return Object.freeze({
effect: "MAYBE_APPLIED" as const,
contractRuntimeFailure: true,
});
}
return Object.freeze({
effect: classification,
contractRuntimeFailure: false,
});
}
export type MutationIntent = Readonly<{
intentId: string;
operationId: string;
canonicalInputIdentity: string;
idempotencyKey?: string;
createdAtMonotonicMs: number;
}>;
export type MutationIntentContext = Readonly<{
intentId: string;
idempotencyKey?: string;
startedBy: "USER" | "FOREGROUND_RETRY" | "OUTBOX_REPLAY";
}>;
export function createMutationIntent(
input: Readonly<{
operationId: string;
canonicalInputIdentity: string;
idempotencyKey?: string;
monotonicNow?: () => number;
}>,
): MutationIntent {
const now = input.monotonicNow ?? (() => performance.now());
return Object.freeze({
intentId: crypto.randomUUID(),
operationId: input.operationId,
canonicalInputIdentity: input.canonicalInputIdentity,
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
createdAtMonotonicMs: now(),
});
}
/** §8.10. Certainty to UI intent. The copy itself is owned by the i18n catalog. */
export function projectCertaintyToUi(
certainty: MutationEffectCertainty,
): "RETRYABLE" | "CHECK_STATUS" | "SUCCESS" {
switch (certainty) {
case "APPLIED_CONFIRMED":
return "SUCCESS";
case "MAYBE_APPLIED":
return "CHECK_STATUS";
default:
return "RETRYABLE";
}
}
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
/**
* §20.4. The single owner of window lifecycle listeners.
*
* No capability adds its own `visibilitychange`, `online`, `offline`, `focus`,
* `pagehide` or `pageshow` listener. They subscribe here instead, so the
* listener count stays constant and leak inspection (§23.14) is meaningful.
*
* §20.6: nothing in this module is a correctness boundary. `beforeunload` is a
* user prompt, never a place to complete a command, write a checkpoint or
* guarantee a lease release.
*/
export type BrowserLifecycleSnapshot = Readonly<{
visibility: "VISIBLE" | "HIDDEN";
connectivityHint: "ONLINE" | "OFFLINE";
pageState: "ACTIVE" | "PAGEHIDE" | "BFCACHE_RESTORED";
generation: number;
}>;
export type BrowserLifecycleEvent =
| Readonly<{ kind: "VISIBILITY_CHANGED"; snapshot: BrowserLifecycleSnapshot }>
| Readonly<{ kind: "ONLINE"; snapshot: BrowserLifecycleSnapshot }>
| Readonly<{ kind: "OFFLINE"; snapshot: BrowserLifecycleSnapshot }>
| Readonly<{ kind: "FOCUS"; snapshot: BrowserLifecycleSnapshot }>
| Readonly<{
kind: "PAGEHIDE";
persisted: boolean;
snapshot: BrowserLifecycleSnapshot;
}>
| Readonly<{
kind: "PAGESHOW";
persisted: boolean;
snapshot: BrowserLifecycleSnapshot;
}>;
export type BrowserLifecycleRuntime = Readonly<{
getSnapshot(): BrowserLifecycleSnapshot;
subscribe(listener: (event: BrowserLifecycleEvent) => void): () => void;
/**
* Registers a dirty-state source. `beforeunload` is attached only while at
* least one source reports dirty, and it uses the browser's standard prompt.
*/
registerDirtySource(isDirty: () => boolean): () => void;
dispose(): void;
}>;
type LifecycleHost = Readonly<{
addEventListener: Window["addEventListener"];
removeEventListener: Window["removeEventListener"];
document?: Pick<Document, "visibilityState"> & {
addEventListener: Document["addEventListener"];
removeEventListener: Document["removeEventListener"];
};
navigator?: Pick<Navigator, "onLine">;
}>;
export function createBrowserLifecycleRuntime(
host: LifecycleHost = globalThis as unknown as LifecycleHost,
): BrowserLifecycleRuntime {
const listeners = new Set<(event: BrowserLifecycleEvent) => void>();
const dirtySources = new Set<() => boolean>();
const document = host.document;
let generation = 1;
let visibility: BrowserLifecycleSnapshot["visibility"] =
document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE";
let connectivityHint: BrowserLifecycleSnapshot["connectivityHint"] =
host.navigator?.onLine === false ? "OFFLINE" : "ONLINE";
let pageState: BrowserLifecycleSnapshot["pageState"] = "ACTIVE";
let disposed = false;
let beforeUnloadAttached = false;
function snapshot(): BrowserLifecycleSnapshot {
return Object.freeze({
visibility,
connectivityHint,
pageState,
generation,
});
}
function publish(event: BrowserLifecycleEvent): void {
for (const listener of listeners) {
try {
listener(event);
} catch {
// One subscriber defect cannot suppress the signal for the others.
}
}
}
const onVisibility = () => {
visibility = document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE";
publish({ kind: "VISIBILITY_CHANGED", snapshot: snapshot() });
};
const onOnline = () => {
connectivityHint = "ONLINE";
publish({ kind: "ONLINE", snapshot: snapshot() });
};
const onOffline = () => {
connectivityHint = "OFFLINE";
publish({ kind: "OFFLINE", snapshot: snapshot() });
};
const onFocus = () => {
publish({ kind: "FOCUS", snapshot: snapshot() });
};
const onPageHide = (event: Event) => {
const persisted = (event as PageTransitionEvent).persisted === true;
pageState = "PAGEHIDE";
publish({ kind: "PAGEHIDE", persisted, snapshot: snapshot() });
};
const onPageShow = (event: Event) => {
const persisted = (event as PageTransitionEvent).persisted === true;
// §20.5. A bfcache restore is a new lifecycle generation, not a fresh boot.
if (persisted) generation += 1;
pageState = persisted ? "BFCACHE_RESTORED" : "ACTIVE";
publish({ kind: "PAGESHOW", persisted, snapshot: snapshot() });
};
const onBeforeUnload = (event: Event) => {
if (!hasDirtyState()) return;
event.preventDefault();
};
function hasDirtyState(): boolean {
for (const isDirty of dirtySources) {
try {
if (isDirty()) return true;
} catch {
// A defective reporter is treated as clean rather than trapping the user.
}
}
return false;
}
function syncBeforeUnload(): void {
const shouldAttach = dirtySources.size > 0;
if (shouldAttach && !beforeUnloadAttached) {
host.addEventListener("beforeunload", onBeforeUnload);
beforeUnloadAttached = true;
} else if (!shouldAttach && beforeUnloadAttached) {
host.removeEventListener("beforeunload", onBeforeUnload);
beforeUnloadAttached = false;
}
}
document?.addEventListener("visibilitychange", onVisibility);
host.addEventListener("online", onOnline);
host.addEventListener("offline", onOffline);
host.addEventListener("focus", onFocus);
host.addEventListener("pagehide", onPageHide);
host.addEventListener("pageshow", onPageShow);
return Object.freeze({
getSnapshot: snapshot,
subscribe(listener) {
if (disposed) return () => {};
listeners.add(listener);
return () => listeners.delete(listener);
},
registerDirtySource(isDirty) {
if (disposed) return () => {};
dirtySources.add(isDirty);
syncBeforeUnload();
return () => {
dirtySources.delete(isDirty);
syncBeforeUnload();
};
},
dispose() {
if (disposed) return;
disposed = true;
document?.removeEventListener("visibilitychange", onVisibility);
host.removeEventListener("online", onOnline);
host.removeEventListener("offline", onOffline);
host.removeEventListener("focus", onFocus);
host.removeEventListener("pagehide", onPageHide);
host.removeEventListener("pageshow", onPageShow);
if (beforeUnloadAttached) {
host.removeEventListener("beforeunload", onBeforeUnload);
beforeUnloadAttached = false;
}
listeners.clear();
dirtySources.clear();
},
});
}
@@ -6,18 +6,47 @@ import {
} from "../../contracts/query-keys.ts";
import type {
CacheScopeSnapshot,
ClientScopeLifecycleEvent,
ClientScopePhase,
ServerStateScopeRuntime,
} from "../../contracts/server-state-scope.ts";
export function createServerStateScopeRuntime(dependencies: Readonly<{
/**
* Steps 4-11 of §10.6 that this runtime does not own directly. Each optional
* capability registers its own closer so the ordering lives in one place rather
* than being re-derived by every subsystem.
*/
export type ScopeResetParticipant = Readonly<{
/** Lower runs earlier; the §10.6 step number is used as the rank. */
order: number;
label: string;
close(): void | Promise<void>;
}>;
export type ServerStateScopeDependencies = Readonly<{
session: Pick<AuthSessionPort, "subscribe">;
queryInvalidation: QueryInvalidationCoordinator;
queryInvalidation: Pick<QueryInvalidationCoordinator, "resetLocal">;
tokenFactory?: () => string;
}>): ServerStateScopeRuntime {
participants?: readonly ScopeResetParticipant[];
activateNextGeneration?: () => void | Promise<void>;
}>;
export function createServerStateScopeRuntime(
dependencies: ServerStateScopeDependencies,
): ServerStateScopeRuntime {
const listeners = new Set<() => void>();
const lifecycleListeners = new Set<
(event: ClientScopeLifecycleEvent) => void
>();
const participants = [...(dependencies.participants ?? [])].sort(
(left, right) => left.order - right.order,
);
let generation = 1;
let identities = newIdentityRegistry(dependencies.tokenFactory);
let fingerprint = scopeFingerprint(dependencies.tokenFactory);
let generationLifetime = new AbortController();
let phase: ClientScopePhase = "READY";
let disposed = false;
let resetChain = Promise.resolve();
@@ -28,42 +57,129 @@ export function createServerStateScopeRuntime(dependencies: Readonly<{
generation: capturedGeneration,
fingerprint,
identities: capturedIdentities,
signal: generationLifetime.signal,
isCurrent: () =>
!disposed &&
phase === "READY" &&
generation === capturedGeneration &&
identities === capturedIdentities,
});
}
let currentSnapshot = createSnapshot();
function publishLifecycle(event: ClientScopeLifecycleEvent): void {
for (const listener of [...lifecycleListeners]) {
try {
listener(event);
} catch {
// One subscriber defect cannot stop the fence from propagating.
}
}
}
function publishSnapshot(): void {
for (const listener of [...listeners]) {
try {
listener();
} catch {
// Subscriber defects are isolated from the mandatory reset sequence.
}
}
}
const unsubscribe = dependencies.session.subscribe(() => {
if (disposed) return;
const previousIdentities = identities;
const previousGeneration = generation;
// §10.6 steps 1-3 are synchronous: increment the generation, invalidate the
// old snapshot, publish FENCED. Nothing between here and READY may render a
// value that belonged to the previous identity.
generationLifetime.abort();
const targetGeneration = ++generation;
phase = "FENCED";
currentSnapshot = createSnapshot();
publishLifecycle(
Object.freeze({ kind: "FENCED" as const, previousGeneration }),
);
publishSnapshot();
resetChain = resetChain
.then(() => dependencies.queryInvalidation.resetLocal())
.catch(() => {})
.finally(() => {
.then(async () => {
let failed = false;
// Steps 4-11: close admission, cancel and clear, release leases.
for (const participant of participants) {
try {
await participant.close();
} catch {
failed = true;
}
}
try {
await dependencies.queryInvalidation.resetLocal();
} catch {
failed = true;
}
previousIdentities.close();
if (disposed || generation !== targetGeneration) return;
if (!failed) {
try {
await dependencies.activateNextGeneration?.();
} catch {
failed = true;
}
}
if (disposed || generation !== targetGeneration) return;
if (failed) {
phase = "FAILED";
currentSnapshot = createSnapshot();
publishLifecycle(
Object.freeze({
kind: "FAILED" as const,
generation: targetGeneration,
}),
);
publishSnapshot();
return;
}
// Steps 12-15: new identity registry, READY, notify, reopen admission.
identities = newIdentityRegistry(dependencies.tokenFactory);
fingerprint = scopeFingerprint(dependencies.tokenFactory);
generationLifetime = new AbortController();
phase = "READY";
currentSnapshot = createSnapshot();
for (const listener of listeners) listener();
publishLifecycle(
Object.freeze({ kind: "READY" as const, snapshot: currentSnapshot }),
);
publishSnapshot();
});
});
return Object.freeze({
getSnapshot: () => currentSnapshot,
subscribe(listener) {
getPhase: () => phase,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeLifecycle(listener: (event: ClientScopeLifecycleEvent) => void) {
lifecycleListeners.add(listener);
return () => lifecycleListeners.delete(listener);
},
dispose() {
if (disposed) return;
disposed = true;
phase = "DISPOSED";
generationLifetime.abort();
unsubscribe();
publishLifecycle(Object.freeze({ kind: "DISPOSED" as const }));
listeners.clear();
lifecycleListeners.clear();
identities.close();
},
});
@@ -237,12 +237,17 @@ export function createTanStackCacheCoordinator(
} catch {
report("reset-flush");
}
let cancellationFailed = false;
try {
await dependencies.queryClient.cancelQueries();
} catch {
report("reset-cancel");
cancellationFailed = true;
}
dependencies.queryClient.clear();
if (cancellationFailed) {
throw new TypeError("mandatory query cancellation failed");
}
})().finally(() => {
resetting = false;
resetPromise = null;
@@ -299,11 +304,9 @@ function buildDefinitions(
}),
);
}
if (definitions.size === 0) {
throw new TypeError(
"Query invalidation registry requires at least one topic.",
);
}
// An empty registry is a legitimate state: a template with no installed
// feature has no invalidation topic. Every per-entry rule above still
// applies, and an unregistered topic still fails at the call site.
return definitions;
}
@@ -0,0 +1,170 @@
/// <reference lib="webworker" />
import { OFFLINE_SYNC_TAG } from "../../contracts/offline-command.ts";
import type {
ServiceWorkerHandlerId,
ServiceWorkerProtocolIdentity,
StaticAssetManifestV1,
} from "../../contracts/service-worker.ts";
import {
createServiceWorkerRuntime,
type WorkerScopeLike,
} from "./service-worker-lifecycle.ts";
import { parseServiceWorkerMessage } from "./service-worker-protocol.ts";
/**
* §17.1. The one physical worker entry for this scope.
*
* PWA lifecycle, verified static asset fetch, Web Push and the optional sync
* wake-up are all handler factories inside this single entry. A second
* registration for any of them is prohibited.
*
* This module is compiled only by `vite.service-worker.config.ts` when the
* static selection is `ACTIVE`; it is never part of the page bundle.
*/
declare const self: ServiceWorkerGlobalScope;
// Build-time virtual modules (§18.3). They resolve through the Service Worker
// Vite config only, so the page bundle can never import a worker asset list.
declare const __CA_SERVICE_WORKER_BUILD_INFO__: ServiceWorkerProtocolIdentity;
declare const __CA_SERVICE_WORKER_ASSETS__: StaticAssetManifestV1 | null;
declare const __CA_SERVICE_WORKER_HANDLERS__: readonly ServiceWorkerHandlerId[];
declare const __CA_RUNTIME_CONFIG_URL__: string;
declare const __CA_RELEASE_MANIFEST_URL__: string;
const identity = __CA_SERVICE_WORKER_BUILD_INFO__;
const handlers = __CA_SERVICE_WORKER_HANDLERS__;
const scope: WorkerScopeLike = {
caches: {
open: (name) => caches.open(name),
keys: () => caches.keys(),
delete: (name) => caches.delete(name),
match: (request) => caches.match(request),
},
clients: {
matchAll: (options) =>
self.clients.matchAll(
options as { type?: "window"; includeUncontrolled?: boolean },
) as Promise<
readonly {
id: string;
url: string;
postMessage(m: unknown): void;
}[]
>,
},
registrationScope: self.registration.scope,
skipWaiting: () => self.skipWaiting(),
fetcher: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init),
async digest(bytes) {
const buffer = await crypto.subtle.digest(
"SHA-256",
bytes.slice().buffer as ArrayBuffer,
);
let hex = "";
for (const byte of new Uint8Array(buffer)) {
hex += byte.toString(16).padStart(2, "0");
}
return `sha256:${hex}`;
},
};
const runtime = createServiceWorkerRuntime(scope, {
identity,
handlers,
manifest: __CA_SERVICE_WORKER_ASSETS__,
runtimeConfigUrl: __CA_RUNTIME_CONFIG_URL__,
releaseManifestUrl: __CA_RELEASE_MANIFEST_URL__,
});
self.addEventListener("install", (event) => {
// §17.10. Install never calls skipWaiting(); activation is a page handshake.
event.waitUntil(runtime.onInstall());
});
self.addEventListener("activate", (event) => {
// §17.12. No clients.claim() in the baseline.
event.waitUntil(runtime.onActivate());
});
self.addEventListener("fetch", (event) => {
const request = event.request;
event.respondWith(
runtime
.onFetch({
method: request.method,
url: request.url,
mode: request.mode,
})
.then((cached) => cached ?? fetch(request)),
);
});
self.addEventListener("message", (event) => {
const parsed = parseServiceWorkerMessage(event.data);
if (!parsed.ok) return;
if (parsed.message.kind === "ACTIVATE_REQUEST") {
event.waitUntil(runtime.onActivateRequest(event.data));
return;
}
if (
parsed.message.kind === "CLIENT_DRAINED" ||
parsed.message.kind === "ACTIVATE_REJECTED"
) {
const source = event.source;
if (source && "id" in source && typeof source.id === "string") {
runtime.onClientMessage(event.data, source.id);
}
return;
}
if (parsed.message.kind === "CACHE_RESET_REQUEST") {
const source = event.source;
if (
source &&
"id" in source &&
typeof source.id === "string" &&
"postMessage" in source &&
typeof source.postMessage === "function"
) {
event.waitUntil(runtime.onCacheResetRequest(event.data, source));
}
}
});
// §17.1 WEB_PUSH composition point.
//
// The Web Push runtime is a handler factory inside this one entry, never a
// second registration. It is not wired here because the template cannot supply
// the two product-owned inputs it needs: a PushAssociationFenceStore over the
// product push control repository, and a WebPushNotificationRegistry of exact
// notification types with the same-origin routes their clicks may open
// (§21.11). Selecting WEB_PUSH means adding, inside a
// `handlers.includes("WEB_PUSH")` guard: import
// createWebPushServiceWorkerRuntime and createServiceWorkerScopeHost from the
// sibling web-push adapter, then call the runtime with the scope host built
// from `self` plus the product fence store and notification registry.
//
// Keeping the import out of the baseline entry is also what lets the realtime
// and Web Push runtime be removed as a pure file deletion (§24.12).
if (handlers.includes("OFFLINE_SYNC_WAKEUP")) {
// §19.18. Wake-up only: the handler records that a sync fired and notifies
// controlled clients. It never sends an authenticated command (§19.20).
self.addEventListener("sync", (rawEvent: Event) => {
const event = rawEvent as ExtendableEvent & { tag?: string };
if (event.tag !== OFFLINE_SYNC_TAG) return;
event.waitUntil(
self.clients.matchAll({ type: "window" }).then((clients) => {
for (const client of clients) {
client.postMessage({
protocolVersion: 1,
kind: "SYNC_WAKE_OBSERVED",
messageId: crypto.randomUUID(),
sourceBuildId: identity.buildId,
});
}
}),
);
});
}
@@ -0,0 +1,442 @@
import {
SERVICE_WORKER_BOUNDS,
isOwnedStaticCacheName,
staticCacheName,
type ServiceWorkerHandlerId,
type ServiceWorkerProtocolIdentity,
type StaticAssetManifestV1,
} from "../../contracts/service-worker.ts";
import {
createServiceWorkerMessage,
parseServiceWorkerMessage,
} from "./service-worker-protocol.ts";
import {
classifyFetch,
installStaticAssets,
selectCachesToDelete,
} from "./service-worker-static-assets.ts";
/**
* §17.9–§17.15. Worker-side lifecycle, expressed against structural types so it
* can be unit-tested outside a real Service Worker global and compiled under
* `tsconfig.service-worker.json` without pulling in DOM globals.
*/
export type WorkerClientLike = Readonly<{
id: string;
url: string;
postMessage(message: unknown): void;
}>;
export type WorkerScopeLike = Readonly<{
caches: Readonly<{
open(cacheName: string): Promise<Cache>;
keys(): Promise<readonly string[]>;
delete(cacheName: string): Promise<boolean>;
match(request: string): Promise<Response | undefined>;
}>;
clients: Readonly<{
matchAll(
options?: Readonly<{
type?: "window";
includeUncontrolled?: boolean;
}>,
): Promise<
readonly WorkerClientLike[]
>;
}>;
registrationScope: string;
skipWaiting(): Promise<void>;
fetcher: typeof fetch;
digest(bytes: Uint8Array): Promise<string>;
}>;
export type WorkerRuntimeConfig = Readonly<{
identity: ServiceWorkerProtocolIdentity;
handlers: readonly ServiceWorkerHandlerId[];
manifest: StaticAssetManifestV1 | null;
runtimeConfigUrl: string;
releaseManifestUrl: string;
}>;
const ACTIVATION_MARKER_URL =
"https://clean-architecture.invalid/__service-worker-activation-v1__";
const ACTIVATION_MARKER_MAX_BYTES = 256;
type ActivationMarker = Readonly<{
cacheName: string;
activationSequence: number;
}>;
export function createServiceWorkerRuntime(
scope: WorkerScopeLike,
config: WorkerRuntimeConfig,
) {
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
const manifestUrls = new Set(
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
);
const consumedNonces = new Set<string>();
type PendingActivation = Readonly<{
requesterBuildId: string;
expectedClientIds: ReadonlySet<string>;
acknowledgedClientIds: Set<string>;
resolve(drained: boolean): void;
timer: ReturnType<typeof setTimeout>;
}>;
const pendingActivations = new Map<string, PendingActivation>();
/**
* §17.9. Without the static asset handler the install step opens zero caches;
* it only registers lifecycle, push and sync handlers.
*/
async function onInstall(): Promise<void> {
if (!staticEnabled || !config.manifest) return;
const outcome = await installStaticAssets(config.manifest, {
caches: scope.caches,
fetcher: scope.fetcher,
digest: scope.digest,
});
if (outcome.kind === "REJECTED") {
throw new Error(`STATIC_INSTALL_REJECTED:${outcome.code}`);
}
}
/**
* §17.15. Delete only owned caches outside the current and one previous
* revision. `clients.claim()` is never called (§17.12).
*/
async function onActivate(): Promise<number> {
if (!staticEnabled || !config.manifest) return 0;
const current = staticCacheName(config.manifest.setDigest);
const names = await scope.caches.keys();
const owned = names.filter(isOwnedStaticCacheName);
const currentCache = await scope.caches.open(current);
const oldCaches = owned.filter((name) => name !== current);
const markers: ActivationMarker[] = [];
for (const name of oldCaches) {
const marker = await readActivationMarker(await scope.caches.open(name), name);
if (marker) markers.push(marker);
}
const currentMarker = await readActivationMarker(currentCache, current);
const highestOld = markers.reduce<ActivationMarker | null>(
(highest, marker) =>
!highest || marker.activationSequence >= highest.activationSequence
? marker
: highest,
null,
);
const previous = highestOld?.cacheName ?? oldCaches.at(-1) ?? null;
if (
!currentMarker ||
currentMarker.activationSequence < (highestOld?.activationSequence ?? 0)
) {
const nextSequence = (highestOld?.activationSequence ?? 0) + 1;
if (!Number.isSafeInteger(nextSequence)) {
throw new Error("SERVICE_WORKER_ACTIVATION_SEQUENCE_EXHAUSTED");
}
await currentCache.put(
ACTIVATION_MARKER_URL,
new Response(
JSON.stringify({
schemaVersion: 1,
cacheName: current,
activationSequence: nextSequence,
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
}
const stale = selectCachesToDelete(names, current, previous);
let deleted = 0;
for (const name of stale) {
if (await scope.caches.delete(name)) deleted += 1;
}
void SERVICE_WORKER_BOUNDS.retainedPreviousCaches;
return deleted;
}
/**
* §18.5–§18.7. A verified cache hit is returned; anything else goes to the
* network and is never written back into the active cache at runtime.
*/
async function onFetch(
request: Readonly<{ method: string; url: string; mode?: string }>,
): Promise<Response | null> {
const classification = classifyFetch({
method: request.method,
requestUrl: request.url,
isNavigation: request.mode === "navigate",
runtimeConfigUrl: config.runtimeConfigUrl,
releaseManifestUrl: config.releaseManifestUrl,
manifestUrls,
});
if (classification !== "VERIFIED_CACHE_FIRST") return null;
const cached = await scope.caches.match(request.url);
if (!cached) return null;
if (cached.status !== 200 || cached.type === "opaque") {
// §18.6. An invalid hit is deleted and treated as a release mismatch.
const current = config.manifest
? staticCacheName(config.manifest.setDigest)
: null;
if (current) {
const cache = await scope.caches.open(current);
await cache.delete(request.url).catch(() => false);
}
return null;
}
return cached;
}
/**
* §17.11. The waiting worker validates the request, drains every controlled
* client, and only then calls `skipWaiting()`.
*/
async function onActivateRequest(
data: unknown,
): Promise<"ACCEPTED" | "REJECTED" | "IGNORED"> {
const parsed = parseServiceWorkerMessage(data);
if (!parsed.ok || parsed.message.kind !== "ACTIVATE_REQUEST") return "IGNORED";
const nonce = parsed.message.nonce;
if (!nonce || consumedNonces.has(nonce)) return "REJECTED";
if (
parsed.message.targetBuildId !== undefined &&
parsed.message.targetBuildId !== config.identity.buildId
) {
return "REJECTED";
}
consumedNonces.add(nonce);
if (consumedNonces.size > 64) {
const oldest = consumedNonces.values().next().value;
if (oldest !== undefined) consumedNonces.delete(oldest);
}
const candidates = await scope.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
const clients = candidates.filter((client) =>
isClientWithinRegistrationScope(client.url, scope.registrationScope),
);
const drained = await drainClients(
clients,
nonce,
parsed.message.sourceBuildId,
);
if (!drained) {
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATE_REJECTED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
}
return "REJECTED";
}
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATE_ACCEPTED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
}
await scope.skipWaiting();
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATED_RELOAD_REQUIRED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
}
return "ACCEPTED";
}
async function drainClients(
clients: readonly WorkerClientLike[],
nonce: string,
requesterBuildId: string,
): Promise<boolean> {
if (clients.length === 0) return false;
const drained = new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
pendingActivations.delete(nonce);
resolve(false);
}, SERVICE_WORKER_BOUNDS.clientDrainMs);
pendingActivations.set(
nonce,
Object.freeze({
requesterBuildId,
expectedClientIds: new Set(clients.map((client) => client.id)),
acknowledgedClientIds: new Set<string>(),
resolve,
timer,
}),
);
});
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "CLIENT_DRAIN_REQUEST",
sourceBuildId: config.identity.buildId,
targetBuildId: requesterBuildId,
nonce,
}),
);
}
return drained;
}
function onClientMessage(data: unknown, sourceClientId: string): void {
const parsed = parseServiceWorkerMessage(data);
if (!parsed.ok || !parsed.message.nonce) return;
if (
parsed.message.targetBuildId !== config.identity.buildId ||
(parsed.message.kind !== "CLIENT_DRAINED" &&
parsed.message.kind !== "ACTIVATE_REJECTED")
) {
return;
}
const pending = pendingActivations.get(parsed.message.nonce);
if (
!pending ||
parsed.message.sourceBuildId !== pending.requesterBuildId ||
!pending.expectedClientIds.has(sourceClientId)
) {
return;
}
if (parsed.message.kind === "ACTIVATE_REJECTED") {
settlePendingActivation(parsed.message.nonce, pending, false);
return;
}
pending.acknowledgedClientIds.add(sourceClientId);
if (
pending.acknowledgedClientIds.size === pending.expectedClientIds.size
) {
settlePendingActivation(parsed.message.nonce, pending, true);
}
}
async function onCacheResetRequest(
data: unknown,
source: WorkerClientLike,
): Promise<void> {
const parsed = parseServiceWorkerMessage(data);
if (
!parsed.ok ||
parsed.message.kind !== "CACHE_RESET_REQUEST" ||
!parsed.message.nonce ||
(parsed.message.targetBuildId !== undefined &&
parsed.message.targetBuildId !== config.identity.buildId)
) {
return;
}
let cachesDeleted = 0;
const names = await scope.caches.keys();
for (const name of names) {
if (!name.startsWith("ca-static-v1-")) continue;
try {
if (await scope.caches.delete(name)) cachesDeleted += 1;
} catch {
return;
}
}
source.postMessage(
createServiceWorkerMessage({
kind: "CACHE_RESET_RESULT",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce: parsed.message.nonce,
cachesDeleted,
}),
);
}
return Object.freeze({
onInstall,
onActivate,
onFetch,
onActivateRequest,
onClientMessage,
onCacheResetRequest,
manifestUrls: manifestUrls as ReadonlySet<string>,
});
function settlePendingActivation(
nonce: string,
pending: PendingActivation,
drained: boolean,
): void {
clearTimeout(pending.timer);
pendingActivations.delete(nonce);
pending.resolve(drained);
}
}
function isClientWithinRegistrationScope(
clientUrl: string,
registrationScope: string,
): boolean {
try {
const client = new URL(clientUrl);
const scope = new URL(registrationScope);
return client.origin === scope.origin && client.href.startsWith(scope.href);
} catch {
return false;
}
}
async function readActivationMarker(
cache: Cache,
expectedCacheName: string,
): Promise<ActivationMarker | null> {
try {
const response = await cache.match(ACTIVATION_MARKER_URL);
if (!response || response.status !== 200) return null;
const declaredLength = response.headers.get("content-length");
if (
declaredLength !== null &&
(!/^\d+$/u.test(declaredLength) ||
Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES)
) {
return null;
}
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) {
return null;
}
const value: unknown = JSON.parse(text);
if (
value === null ||
typeof value !== "object" ||
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
(value as { cacheName?: unknown }).cacheName !== expectedCacheName ||
!Number.isSafeInteger(
(value as { activationSequence?: unknown }).activationSequence,
) ||
((value as { activationSequence: number }).activationSequence ?? 0) < 1
) {
return null;
}
return Object.freeze({
cacheName: expectedCacheName,
activationSequence: (value as { activationSequence: number })
.activationSequence,
});
} catch {
return null;
}
}
@@ -0,0 +1,399 @@
import {
SERVICE_WORKER_BOUNDS,
type InstalledServiceWorkerSelection,
type ServiceWorkerActivationOutcome,
type ServiceWorkerResetOutcome,
type ServiceWorkerRuntimeHost,
type ServiceWorkerStartOutcome,
} from "../../contracts/service-worker.ts";
import {
createNonceRegistry,
createServiceWorkerMessage,
parseServiceWorkerMessage,
} from "./service-worker-protocol.ts";
import {
expectedServiceWorkerUrls,
isOwnedRegistration,
purgeOwnedResources,
removeOwnedRegistration,
} from "./service-worker-removal.ts";
/**
* §17.5–§17.16. The page-side controller.
*
* Registration happens after Runtime Config, release and contract set have all
* validated and the first React effect has committed. The controller never
* calls `skipWaiting()` blindly and never calls `clients.claim()`.
*/
export type ActivationBlocker = () => boolean;
export type PageControllerDependencies = Readonly<{
selection: InstalledServiceWorkerSelection | null;
/** True when static selection is ACTIVE but Runtime Config disabled it. */
disabledCleanup: boolean;
routerBasePath: string;
origin: string;
buildId: string;
container?: ServiceWorkerContainer;
caches?: CacheStorage;
/** §17.10. Any blocker returning true rejects automatic activation. */
blockers?: readonly ActivationBlocker[];
now?: () => number;
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
}>;
export function createServiceWorkerPageController(
dependencies: PageControllerDependencies,
): ServiceWorkerRuntimeHost {
const nonces = createNonceRegistry();
const now = dependencies.now ?? (() => Date.now());
const urls = expectedServiceWorkerUrls(
dependencies.routerBasePath,
dependencies.origin,
);
let registrationPromise: Promise<ServiceWorkerRegistration> | null = null;
let registration: ServiceWorkerRegistration | null = null;
let messageListener: ((event: MessageEvent) => void) | null = null;
let updateTimer: ReturnType<typeof setInterval> | null = null;
let stopped = false;
const pendingStops = new Set<() => void>();
const observe = (event: string, outcome: string) =>
dependencies.observe?.({ event, outcome });
function isBlocked(): boolean {
for (const blocker of dependencies.blockers ?? []) {
try {
if (blocker()) return true;
} catch {
// A defective blocker is treated as blocking: never activate on doubt.
return true;
}
}
return false;
}
async function start(): Promise<ServiceWorkerStartOutcome> {
if (stopped) return failed("STOPPED");
const container = dependencies.container;
// §3.6 / §17.6. Static ACTIVE plus runtime DISABLED performs exactly one
// owned-registration lookup and at most one unregister. No new register, no
// cache deletion, no message or update timer.
if (dependencies.disabledCleanup) {
if (!container) return Object.freeze({ kind: "DISABLED" as const });
const outcome = await removeOwnedRegistration({
container,
routerBasePath: dependencies.routerBasePath,
origin: dependencies.origin,
});
observe("disable_cleanup", outcome.kind);
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
return Object.freeze({ kind: "DISABLED" as const });
}
const selection = dependencies.selection;
// §3.6 / §17.3 `null`: zero registration lookups and zero Cache Storage
// access. The controller must not even probe.
if (!selection) return Object.freeze({ kind: "DISABLED" as const });
if (!container) return Object.freeze({ kind: "INCOMPATIBLE" as const });
if (selection.mode === "REMOVE_REGISTRATION") {
const outcome = await removeOwnedRegistration({
container,
routerBasePath: dependencies.routerBasePath,
origin: dependencies.origin,
});
observe("remove_registration", outcome.kind);
return Object.freeze({ kind: "DISABLED" as const });
}
if (selection.mode === "PURGE_OWNED_RESOURCES") {
const outcome = await purgeOwnedResources({
container,
...(dependencies.caches ? { caches: dependencies.caches } : {}),
routerBasePath: dependencies.routerBasePath,
origin: dependencies.origin,
});
observe("purge_owned_resources", outcome.kind);
return Object.freeze({ kind: "DISABLED" as const });
}
// §17.5. StrictMode's repeated effect returns the same in-flight promise
// instead of issuing a second registration.
registrationPromise ??= container.register(urls.scriptHref, {
scope: urls.scopePath,
type: "module",
updateViaCache: "none",
});
let installedRegistration: ServiceWorkerRegistration;
try {
installedRegistration = await registrationPromise;
} catch {
registrationPromise = null;
observe("register", "FAILED");
return failed("REGISTRATION_FAILED");
}
if (stopped) return failed("STOPPED");
registration = installedRegistration;
if (
!isOwnedRegistration({
registration,
expectedScopeHref: urls.scopeHref,
expectedScriptHref: urls.scriptHref,
})
) {
observe("register", "OWNERSHIP_MISMATCH");
return Object.freeze({ kind: "INCOMPATIBLE" as const });
}
attachMessageListener(container);
scheduleUpdateChecks();
if (registration.waiting) {
observe("register", "UPDATE_WAITING");
return Object.freeze({ kind: "UPDATE_WAITING" as const });
}
// §17.13. Without `clients.claim()` the first install leaves this page
// uncontrolled. That is reported, never silently reloaded.
if (registration.active && !container.controller) {
observe("register", "RELOAD_TO_ENABLE");
return Object.freeze({ kind: "RELOAD_TO_ENABLE" as const });
}
observe("register", "ACTIVE");
return Object.freeze({
kind: "ACTIVE" as const,
buildId: dependencies.buildId,
});
}
function attachMessageListener(container: ServiceWorkerContainer): void {
if (messageListener) return;
messageListener = (event: MessageEvent) => {
if (event.origin && event.origin !== dependencies.origin) return;
const parsed = parseServiceWorkerMessage(event.data);
if (!parsed.ok) {
observe("message", parsed.code);
return;
}
if (
parsed.message.targetBuildId !== undefined &&
parsed.message.targetBuildId !== dependencies.buildId
) {
observe("message", "TARGET_BUILD_MISMATCH");
return;
}
if (parsed.message.kind === "CLIENT_DRAIN_REQUEST") {
const nonce = parsed.message.nonce;
const source = event.source;
if (!nonce || !canPostMessage(source)) {
observe("client_drain", "MALFORMED");
return;
}
const rejected = isBlocked();
source.postMessage(
createServiceWorkerMessage({
kind: rejected ? "ACTIVATE_REJECTED" : "CLIENT_DRAINED",
sourceBuildId: dependencies.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
observe("client_drain", rejected ? "BLOCKED" : "DRAINED");
return;
}
observe("message", parsed.message.kind);
};
container.addEventListener("message", messageListener);
}
function scheduleUpdateChecks(): void {
// §17.14. At most one check per 6 hours, and none while the page is hidden.
if (updateTimer) return;
updateTimer = setInterval(() => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
return;
}
void registration?.update().catch(() => {
// A failed update check never fails a product flow.
});
}, SERVICE_WORKER_BOUNDS.updateCheckIntervalMs);
}
/**
* §17.11. Activation is a handshake: every controlled client must close new
* admission and acknowledge within 30s. One missing client rejects it.
*/
async function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
const waiting = registration?.waiting;
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
if (isBlocked()) {
observe("activation", "BLOCKED_DIRTY_CLIENT");
return Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const });
}
const nonce = nonces.issue();
const deadline = now() + SERVICE_WORKER_BOUNDS.clientDrainMs;
const accepted = await new Promise<ServiceWorkerActivationOutcome>(
(resolve) => {
const container = dependencies.container;
if (!container) {
resolve(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
return;
}
let settled = false;
const finish = (outcome: ServiceWorkerActivationOutcome) => {
if (settled) return;
settled = true;
nonces.consume(nonce);
clearTimeout(timer);
container.removeEventListener("message", onMessage);
pendingStops.delete(onStop);
resolve(outcome);
};
const onMessage = (event: MessageEvent) => {
const parsed = parseServiceWorkerMessage(event.data);
if (!parsed.ok) return;
if (
parsed.message.targetBuildId !== undefined &&
parsed.message.targetBuildId !== dependencies.buildId
) {
return;
}
if (
parsed.message.kind === "ACTIVATE_REJECTED" &&
parsed.message.nonce === nonce
) {
finish(Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const }));
return;
}
if (
parsed.message.kind === "ACTIVATED_RELOAD_REQUIRED" &&
parsed.message.nonce === nonce
) {
finish(
Object.freeze({ kind: "ACTIVATED_RELOAD_REQUIRED" as const }),
);
}
};
const onStop = () =>
finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" }));
const timer = setTimeout(
() => finish(Object.freeze({ kind: "CLIENT_DRAIN_TIMEOUT" as const })),
Math.max(0, deadline - now()),
);
pendingStops.add(onStop);
container.addEventListener("message", onMessage);
try {
waiting.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATE_REQUEST",
sourceBuildId: dependencies.buildId,
nonce,
}),
);
} catch {
finish(Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }));
}
},
);
observe("activation", accepted.kind);
return accepted;
}
/** §18.10. Static caches only; the registration itself is left in place. */
async function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
const container = dependencies.container;
if (!container?.controller) {
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
}
const nonce = nonces.issue();
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
let settled = false;
const finish = (outcome: ServiceWorkerResetOutcome) => {
if (settled) return;
settled = true;
nonces.consume(nonce);
clearTimeout(timer);
container.removeEventListener("message", onMessage);
pendingStops.delete(onStop);
resolve(outcome);
};
const onMessage = (event: MessageEvent) => {
if (event.origin && event.origin !== dependencies.origin) return;
const parsed = parseServiceWorkerMessage(event.data);
if (
!parsed.ok ||
parsed.message.kind !== "CACHE_RESET_RESULT" ||
parsed.message.targetBuildId !== dependencies.buildId ||
parsed.message.nonce !== nonce
) {
return;
}
finish(
Object.freeze({
kind: "RESET" as const,
cachesDeleted: parsed.message.cachesDeleted ?? 0,
}),
);
};
const onStop = () =>
finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" }));
const timer = setTimeout(
() =>
finish(
Object.freeze({ kind: "FAILED" as const, code: "RESET_TIMEOUT" }),
),
SERVICE_WORKER_BOUNDS.clientDrainMs,
);
pendingStops.add(onStop);
container.addEventListener("message", onMessage);
try {
container.controller?.postMessage(
createServiceWorkerMessage({
kind: "CACHE_RESET_REQUEST",
sourceBuildId: dependencies.buildId,
nonce,
}),
);
observe("cache_reset", "REQUESTED");
} catch {
finish(
Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }),
);
}
});
}
/** §17.16. Ordinary shutdown removes listeners and timers; it never unregisters. */
async function stop(): Promise<void> {
stopped = true;
for (const stopPending of [...pendingStops]) stopPending();
pendingStops.clear();
if (updateTimer) {
clearInterval(updateTimer);
updateTimer = null;
}
if (messageListener && dependencies.container) {
dependencies.container.removeEventListener("message", messageListener);
messageListener = null;
}
nonces.clear();
}
return Object.freeze({ start, requestActivation, resetOwnedCaches, stop });
}
function canPostMessage(
source: MessageEventSource | null,
): source is MessageEventSource & { postMessage(message: unknown): void } {
return !!source && typeof source.postMessage === "function";
}
function failed(code: string): ServiceWorkerStartOutcome {
return Object.freeze({ kind: "FAILED" as const, code });
}
@@ -0,0 +1,178 @@
import {
SERVICE_WORKER_PROTOCOL_VERSION,
type ServiceWorkerMessage,
type ServiceWorkerMessageKind,
} from "../../contracts/service-worker.ts";
/**
* §17.8. Message protocol shared by the page controller and the worker entry.
*
* Only structural types are used here: this module is compiled into both the
* DOM realm and the WebWorker realm, so it must not reference a global from
* either one.
*/
const MESSAGE_KINDS: ReadonlySet<string> = new Set<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",
]);
const ID = /^[A-Za-z0-9._:-]{1,128}$/;
export type ParsedMessage =
| Readonly<{ ok: true; message: ServiceWorkerMessage }>
| Readonly<{
ok: false;
code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND";
}>;
/**
* Exact key set, exact protocol version, bounded identifiers. Anything else is
* rejected rather than partially interpreted: a postMessage payload is an
* untrusted runtime input (§21.1).
*/
export function parseServiceWorkerMessage(value: unknown): ParsedMessage {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return reject("MALFORMED");
}
const candidate = value as Record<string, unknown>;
const allowed = new Set([
"protocolVersion",
"kind",
"messageId",
"sourceBuildId",
"targetBuildId",
"nonce",
"cachesDeleted",
]);
for (const key of Object.keys(candidate)) {
if (!allowed.has(key)) return reject("MALFORMED");
}
if (candidate.protocolVersion !== SERVICE_WORKER_PROTOCOL_VERSION) {
return reject("PROTOCOL_MISMATCH");
}
if (typeof candidate.kind !== "string" || !MESSAGE_KINDS.has(candidate.kind)) {
return reject("UNKNOWN_KIND");
}
if (
typeof candidate.messageId !== "string" ||
!ID.test(candidate.messageId) ||
typeof candidate.sourceBuildId !== "string" ||
!ID.test(candidate.sourceBuildId)
) {
return reject("MALFORMED");
}
if (
candidate.cachesDeleted !== undefined &&
(!Number.isSafeInteger(candidate.cachesDeleted) ||
(candidate.cachesDeleted as number) < 0 ||
(candidate.cachesDeleted as number) > 1_024)
) {
return reject("MALFORMED");
}
if (
(candidate.kind === "CACHE_RESET_RESULT") !==
(candidate.cachesDeleted !== undefined)
) {
return reject("MALFORMED");
}
if (
candidate.targetBuildId !== undefined &&
(typeof candidate.targetBuildId !== "string" ||
!ID.test(candidate.targetBuildId))
) {
return reject("MALFORMED");
}
if (
candidate.nonce !== undefined &&
(typeof candidate.nonce !== "string" || !ID.test(candidate.nonce))
) {
return reject("MALFORMED");
}
return Object.freeze({
ok: true as const,
message: Object.freeze({
protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION,
kind: candidate.kind as ServiceWorkerMessageKind,
messageId: candidate.messageId,
sourceBuildId: candidate.sourceBuildId,
...(candidate.targetBuildId === undefined
? {}
: { targetBuildId: candidate.targetBuildId }),
...(candidate.nonce === undefined ? {} : { nonce: candidate.nonce }),
...(candidate.cachesDeleted === undefined
? {}
: { cachesDeleted: candidate.cachesDeleted as number }),
}),
});
}
export function createServiceWorkerMessage(
input: Readonly<{
kind: ServiceWorkerMessageKind;
sourceBuildId: string;
targetBuildId?: string;
nonce?: string;
cachesDeleted?: number;
messageId?: string;
}>,
): ServiceWorkerMessage {
return Object.freeze({
protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION,
kind: input.kind,
messageId: input.messageId ?? randomId(),
sourceBuildId: input.sourceBuildId,
...(input.targetBuildId === undefined
? {}
: { targetBuildId: input.targetBuildId }),
...(input.nonce === undefined ? {} : { nonce: input.nonce }),
...(input.cachesDeleted === undefined
? {}
: { cachesDeleted: input.cachesDeleted }),
});
}
/** One-time nonce store. A nonce is consumed on first match and never reused. */
export function createNonceRegistry(maximumEntries = 32) {
const nonces = new Set<string>();
return Object.freeze({
issue(): string {
if (nonces.size >= maximumEntries) {
const oldest = nonces.values().next().value;
if (oldest !== undefined) nonces.delete(oldest);
}
const nonce = randomId();
nonces.add(nonce);
return nonce;
},
consume(nonce: string | undefined): boolean {
if (!nonce || !nonces.has(nonce)) return false;
nonces.delete(nonce);
return true;
},
clear(): void {
nonces.clear();
},
});
}
function randomId(): string {
return crypto.randomUUID();
}
function reject(
code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND",
): ParsedMessage {
return Object.freeze({ ok: false as const, code });
}
@@ -0,0 +1,169 @@
import {
isOwnedStaticCacheName,
SERVICE_WORKER_SCRIPT_PATH,
type ServiceWorkerRemovalOutcome,
} from "../../contracts/service-worker.ts";
/**
* §17.4 / §17.17. Exact ownership check and staged removal.
*
* A registration is only ours when the scope matches exactly and every present
* worker's script URL is same-origin, with at least one matching the expected
* script and none pointing anywhere else. A scope-prefix guess is never enough:
* a foreign registration must never be unregistered.
*/
export type ServiceWorkerContainerLike = Readonly<{
getRegistration(
clientUrl?: string,
): Promise<ServiceWorkerRegistration | undefined>;
}>;
export type CacheStorageLike = Readonly<{
keys(): Promise<readonly string[]>;
delete(cacheName: string): Promise<boolean>;
}>;
export type OwnershipInput = Readonly<{
registration: ServiceWorkerRegistration;
expectedScopeHref: string;
expectedScriptHref: string;
}>;
export function isOwnedRegistration(input: OwnershipInput): boolean {
const { registration, expectedScopeHref, expectedScriptHref } = input;
if (registration.scope !== expectedScopeHref) return false;
const expectedOrigin = new URL(expectedScriptHref).origin;
const present = [
registration.installing,
registration.waiting,
registration.active,
].filter((worker): worker is ServiceWorker => worker !== null);
if (present.length === 0) return false;
let matched = false;
for (const worker of present) {
let scriptOrigin: string;
try {
scriptOrigin = new URL(worker.scriptURL).origin;
} catch {
return false;
}
if (scriptOrigin !== expectedOrigin) return false;
if (worker.scriptURL === expectedScriptHref) {
matched = true;
} else {
// A present worker running a different script means this registration is
// not exclusively ours.
return false;
}
}
return matched;
}
export function expectedServiceWorkerUrls(
routerBasePath: string,
origin: string,
): Readonly<{ scopeHref: string; scriptHref: string; scopePath: string }> {
const scope = new URL(routerBasePath, origin);
const script = new URL(SERVICE_WORKER_SCRIPT_PATH, scope);
return Object.freeze({
scopeHref: scope.href,
scriptHref: script.href,
scopePath: scope.pathname,
});
}
export type RemovalDependencies = Readonly<{
container: ServiceWorkerContainerLike;
caches?: CacheStorageLike;
routerBasePath: string;
origin: string;
}>;
/**
* `REMOVE_REGISTRATION`: unregister only, caches retained so a rollback within
* the retention window still finds its verified assets.
*/
export async function removeOwnedRegistration(
dependencies: RemovalDependencies,
): Promise<ServiceWorkerRemovalOutcome> {
const urls = expectedServiceWorkerUrls(
dependencies.routerBasePath,
dependencies.origin,
);
let registration: ServiceWorkerRegistration | undefined;
try {
registration = await dependencies.container.getRegistration(urls.scopePath);
} catch {
return Object.freeze({ kind: "FAILED" as const, operation: "LOOKUP" as const });
}
if (!registration) return Object.freeze({ kind: "ABSENT" as const });
if (
!isOwnedRegistration({
registration,
expectedScopeHref: urls.scopeHref,
expectedScriptHref: urls.scriptHref,
})
) {
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
}
try {
await registration.unregister();
} catch {
return Object.freeze({
kind: "FAILED" as const,
operation: "UNREGISTER" as const,
});
}
return Object.freeze({ kind: "UNREGISTERED" as const });
}
/**
* `PURGE_OWNED_RESOURCES`: repeat the unregister check, then delete only caches
* whose name parses as ours. Outbox, OPFS and user file data are untouched, and
* unregistering is never confused with cache deletion.
*/
export async function purgeOwnedResources(
dependencies: RemovalDependencies,
): Promise<ServiceWorkerRemovalOutcome> {
const removal = await removeOwnedRegistration(dependencies);
if (removal.kind === "OWNERSHIP_MISMATCH" || removal.kind === "FAILED") {
return removal;
}
const cacheStorage = dependencies.caches;
if (!cacheStorage) {
return Object.freeze({
kind: "PURGED" as const,
cachesDeleted: 0,
metadataDeleted: 0,
});
}
let names: readonly string[];
try {
names = await cacheStorage.keys();
} catch {
return Object.freeze({ kind: "FAILED" as const, operation: "PURGE" as const });
}
let cachesDeleted = 0;
for (const name of names) {
if (!isOwnedStaticCacheName(name)) continue;
try {
if (await cacheStorage.delete(name)) cachesDeleted += 1;
} catch {
return Object.freeze({
kind: "FAILED" as const,
operation: "PURGE" as const,
});
}
}
return Object.freeze({
kind: "PURGED" as const,
cachesDeleted,
metadataDeleted: 0,
});
}
@@ -0,0 +1,359 @@
import {
isOwnedStaticCacheName,
SERVICE_WORKER_BOUNDS,
staticCacheName,
type StaticAssetManifestV1,
} from "../../contracts/service-worker.ts";
/**
* §17.9 / §18. Static asset install and fetch classification.
*
* Only immutable hashed build assets are cached, all-or-nothing, verified at
* install time. Navigation, runtime config, the release manifest and every API
* response are network-only, and no runtime response is ever written into the
* active cache.
*/
export type FetchClassification =
| "NETWORK_PASSTHROUGH"
| "NETWORK_ONLY"
| "VERIFIED_CACHE_FIRST";
export type ClassificationInput = Readonly<{
method: string;
requestUrl: string;
isNavigation: boolean;
runtimeConfigUrl: string;
releaseManifestUrl: string;
manifestUrls: ReadonlySet<string>;
}>;
/**
* §18.5. Order matters: the exact static hit is evaluated before the generic
* network passthrough, because an API base may legitimately be `/`.
*/
export function classifyFetch(input: ClassificationInput): FetchClassification {
if (input.method !== "GET") return "NETWORK_PASSTHROUGH";
if (input.isNavigation) return "NETWORK_ONLY";
if (
sameResource(input.requestUrl, input.runtimeConfigUrl) ||
sameResource(input.requestUrl, input.releaseManifestUrl)
) {
return "NETWORK_ONLY";
}
if (input.manifestUrls.has(input.requestUrl)) return "VERIFIED_CACHE_FIRST";
return "NETWORK_PASSTHROUGH";
}
function sameResource(left: string, right: string): boolean {
try {
const a = new URL(left);
const b = new URL(right, left);
return a.origin === b.origin && a.pathname === b.pathname;
} catch {
return false;
}
}
export type InstallOutcome =
| Readonly<{ kind: "INSTALLED"; cacheName: string; assets: number }>
| Readonly<{
kind: "REJECTED";
code:
| "MANIFEST_INVALID"
| "ASSET_COUNT_EXCEEDED"
| "ASSET_TOO_LARGE"
| "ASSET_SET_TOO_LARGE"
| "INSTALL_DEADLINE_EXCEEDED"
| "FETCH_FAILED"
| "STATUS_INVALID"
| "CONTENT_TYPE_INVALID"
| "BYTES_MISMATCH"
| "INTEGRITY_MISMATCH"
| "QUOTA_EXCEEDED";
}>;
export type InstallDependencies = Readonly<{
caches: Readonly<{
open(cacheName: string): Promise<Cache>;
delete(cacheName: string): Promise<boolean>;
}>;
fetcher: typeof fetch;
digest(bytes: Uint8Array): Promise<string>;
}>;
export function validateStaticAssetManifest(
manifest: StaticAssetManifestV1,
): InstallOutcome | null {
const bounds = SERVICE_WORKER_BOUNDS;
if (
manifest.schemaVersion !== 1 ||
!/^sha256:[0-9a-f]{64}$/.test(manifest.setDigest)
) {
return rejected("MANIFEST_INVALID");
}
if (manifest.assets.length > bounds.assets) {
return rejected("ASSET_COUNT_EXCEEDED");
}
let total = 0;
for (const asset of manifest.assets) {
if (
!asset.url ||
!/^sha256:[0-9a-f]{64}$/.test(asset.sha256) ||
!Number.isSafeInteger(asset.bytes) ||
asset.bytes < 0
) {
return rejected("MANIFEST_INVALID");
}
if (asset.bytes > bounds.singleAssetBytes) return rejected("ASSET_TOO_LARGE");
total += asset.bytes;
}
if (total > bounds.assetSetBytes) return rejected("ASSET_SET_TOO_LARGE");
return null;
}
/**
* §17.9. A partial candidate is never used: any failure deletes the candidate
* cache and rejects install, leaving the previous verified revision in place.
*/
export async function installStaticAssets(
manifest: StaticAssetManifestV1,
dependencies: InstallDependencies,
): Promise<InstallOutcome> {
const invalid = validateStaticAssetManifest(manifest);
if (invalid) return invalid;
const cacheName = staticCacheName(manifest.setDigest);
const abortController = new AbortController();
let deadlineExceeded = false;
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<InstallOutcome>((resolve) => {
deadlineTimer = setTimeout(() => {
deadlineExceeded = true;
abortController.abort();
resolve(rejected("INSTALL_DEADLINE_EXCEEDED"));
}, SERVICE_WORKER_BOUNDS.installDeadlineMs);
});
const installation = installCandidate(
manifest,
cacheName,
dependencies,
abortController,
);
const raced = await Promise.race([installation, deadline]);
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
const outcome = deadlineExceeded
? rejected("INSTALL_DEADLINE_EXCEEDED")
: raced;
if (outcome.kind === "REJECTED") {
await dependencies.caches.delete(cacheName).catch(() => false);
}
return outcome;
}
async function installCandidate(
manifest: StaticAssetManifestV1,
cacheName: string,
dependencies: InstallDependencies,
abortController: AbortController,
): Promise<InstallOutcome> {
const signal = abortController.signal;
let cache: Cache;
try {
cache = await dependencies.caches.open(cacheName);
} catch {
return rejected("QUOTA_EXCEEDED");
}
const queue = [...manifest.assets];
let failure: InstallOutcome | null = null;
const worker = async (): Promise<void> => {
for (;;) {
if (failure) return;
const asset = queue.shift();
if (!asset) return;
const outcome = await storeAsset(asset, cache, dependencies, signal);
if (outcome) {
failure ??= outcome;
abortController.abort();
return;
}
}
};
await Promise.all(
Array.from({ length: SERVICE_WORKER_BOUNDS.fetchConcurrency }, worker),
);
if (failure) return failure;
return Object.freeze({
kind: "INSTALLED" as const,
cacheName,
assets: manifest.assets.length,
});
}
async function storeAsset(
asset: StaticAssetManifestV1["assets"][number],
cache: Cache,
dependencies: InstallDependencies,
signal: AbortSignal,
): Promise<InstallOutcome | null> {
if (signal.aborted) return rejected("FETCH_FAILED");
let response: Response;
try {
const fetched = await abortable(
dependencies.fetcher(asset.url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
signal,
}),
signal,
);
if (fetched === ABORTED) return rejected("FETCH_FAILED");
response = fetched;
} catch {
return rejected("FETCH_FAILED");
}
if (response.status !== 200 || response.type === "opaque") {
return rejected("STATUS_INVALID");
}
const contentType = response.headers.get("content-type") ?? "";
if (
contentType.split(";", 1)[0]?.trim().toLowerCase() !==
asset.contentType.toLowerCase()
) {
return rejected("CONTENT_TYPE_INVALID");
}
const body = await readBoundedBody(response, asset.bytes, signal);
if (!body.ok) return rejected(body.code);
const bytes = body.bytes;
const digest = await abortable(dependencies.digest(bytes), signal);
if (digest === ABORTED) return rejected("FETCH_FAILED");
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
try {
if (signal.aborted) return rejected("FETCH_FAILED");
await cache.put(
asset.url,
new Response(bytes.slice(), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
);
} catch {
return rejected("QUOTA_EXCEEDED");
}
return null;
}
const ABORTED = Symbol("service-worker-install-aborted");
async function abortable<Value>(
operation: Promise<Value>,
signal: AbortSignal,
): Promise<Value | typeof ABORTED> {
if (signal.aborted) return ABORTED;
let onAbort: (() => void) | undefined;
const aborted = new Promise<typeof ABORTED>((resolve) => {
onAbort = () => resolve(ABORTED);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function readBoundedBody(
response: Response,
expectedBytes: number,
signal: AbortSignal,
): Promise<
| Readonly<{ ok: true; bytes: Uint8Array }>
| Readonly<{ ok: false; code: "BYTES_MISMATCH" | "FETCH_FAILED" }>
> {
const declaredLength = response.headers.get("content-length");
if (
declaredLength !== null &&
/^\d+$/u.test(declaredLength) &&
Number(declaredLength) !== expectedBytes
) {
await response.body?.cancel().catch(() => {});
return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
}
if (!response.body) {
return expectedBytes === 0
? Object.freeze({ ok: true as const, bytes: new Uint8Array() })
: Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const result = await abortable(reader.read(), signal);
if (result === ABORTED) {
await reader.cancel().catch(() => {});
return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const });
}
if (result.done) break;
total += result.value.byteLength;
if (total > expectedBytes) {
await reader.cancel().catch(() => {});
return Object.freeze({
ok: false as const,
code: "BYTES_MISMATCH" as const,
});
}
chunks.push(result.value);
}
} catch {
return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const });
} finally {
reader.releaseLock();
}
if (total !== expectedBytes) {
return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return Object.freeze({ ok: true as const, bytes });
}
/**
* §17.15. Keep the current revision plus exactly one previous verified cache.
* A cache found outside the owned prefix is left alone; a cache holding config,
* manifest or API data is a security violation and is deleted.
*/
export function selectCachesToDelete(
names: readonly string[],
currentCacheName: string,
previousCacheName: string | null,
): readonly string[] {
return Object.freeze(
names.filter(
(name) =>
isOwnedStaticCacheName(name) &&
name !== currentCacheName &&
name !== previousCacheName,
),
);
}
function rejected(code: Extract<InstallOutcome, { kind: "REJECTED" }>["code"]) {
return Object.freeze({ kind: "REJECTED" as const, code });
}
@@ -591,6 +591,8 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
});
let connection: IDBDatabase | null = null;
let openingRequest: IDBOpenDBRequest | null = null;
let openingPromise: Promise<BrowserDataResult<void>> | null = null;
let activeOpeningGeneration: object | null = null;
let cancelPendingOpen: (() => void) | null = null;
let disposed = false;
@@ -654,56 +656,61 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
updateStatus({ kind: "CLOSED", reason: "FORCED" });
}
async function open(
signal?: AbortSignal,
function waitForOpeningAttempt(
attempt: Promise<BrowserDataResult<void>>,
signal: AbortSignal | undefined,
): Promise<BrowserDataResult<void>> {
const operation = "INDEXEDDB_OPEN" as const;
if (disposed) return observeResult(operation, unavailable(operation));
if (connection) {
return observeResult(operation, browserDataSuccess(undefined));
}
const cancelled = abortedResult(signal, operation);
if (cancelled) return observeResult(operation, cancelled);
if (!factory) {
return observeResult(
operation,
browserDataFailure("UNSUPPORTED", operation, {
recovery: "ONLINE_ONLY",
}),
);
}
if (openingRequest) {
const result =
status.kind === "BLOCKED"
? browserDataFailure("BLOCKED", operation, {
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
})
: unavailable(operation);
return observeResult(operation, result);
}
if (cancelled) return Promise.resolve(cancelled);
updateStatus({
kind: "OPENING",
targetVersion: dependencies.schemaVersion,
return new Promise<BrowserDataResult<void>>((resolve) => {
let callerSettled = false;
const finishCaller = (result: BrowserDataResult<void>) => {
if (callerSettled) return;
callerSettled = true;
signal?.removeEventListener("abort", onAbort);
resolve(result);
};
function onAbort(): void {
finishCaller(browserDataFailure("ABORTED", operation));
}
signal?.addEventListener("abort", onAbort, { once: true });
void attempt.then(finishCaller, () => finishCaller(unavailable(operation)));
});
}
return await new Promise<BrowserDataResult<void>>((resolve) => {
function startOpeningAttempt(
availableFactory: IDBFactory,
generation: object,
): Promise<BrowserDataResult<void>> {
const operation = "INDEXEDDB_OPEN" as const;
return new Promise<BrowserDataResult<void>>((resolve) => {
const settleNativeRequest = () => {
if (activeOpeningGeneration !== generation) return;
activeOpeningGeneration = null;
openingRequest = null;
openingPromise = null;
cancelPendingOpen = null;
};
let request: IDBOpenDBRequest;
try {
request = factory.open(
request = availableFactory.open(
databaseName,
dependencies.schemaVersion,
);
} catch (error) {
const result = mapIndexedDbException(error, operation);
updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" });
resolve(observeResult(operation, result));
resolve(result);
queueMicrotask(settleNativeRequest);
return;
}
openingRequest = request;
let callerSettled = false;
let requestSettled = false;
let migrationFailed = false;
let policyBindingRejected = false;
let appliedMigrations = 0;
@@ -715,17 +722,14 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
blockedTimer = undefined;
}
};
const detachAbort = () => signal?.removeEventListener("abort", onAbort);
const finishCaller = (result: BrowserDataResult<void>) => {
if (callerSettled) return;
callerSettled = true;
const finishOpeningAttempt = (result: BrowserDataResult<void>) => {
if (requestSettled) return;
requestSettled = true;
clearBlockedTimer();
detachAbort();
resolve(observeResult(operation, result));
resolve(result);
};
const settleLateRequest = () => {
openingRequest = null;
cancelPendingOpen = null;
settleNativeRequest();
};
const abortUpgrade = () => {
try {
@@ -734,15 +738,10 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
// An open request cannot otherwise be cancelled.
}
};
function onAbort(): void {
abortUpgrade();
finishCaller(browserDataFailure("ABORTED", operation));
}
cancelPendingOpen = () => {
abortUpgrade();
finishCaller(unavailable(operation));
finishOpeningAttempt(unavailable(operation));
};
signal?.addEventListener("abort", onAbort, { once: true });
request.onupgradeneeded = (event) => {
const transaction = request.transaction;
@@ -789,7 +788,7 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
});
if (blockedTimer === undefined) {
blockedTimer = scheduler.setTimeout(() => {
finishCaller(
finishOpeningAttempt(
browserDataFailure("BLOCKED", operation, {
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
@@ -816,24 +815,24 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
{ recovery: "READ_ONLY" },
);
observeResult("INDEXEDDB_MIGRATE", result);
finishCaller(result);
finishOpeningAttempt(result);
return;
}
if (policyBindingRejected) {
finishCaller(
finishOpeningAttempt(
browserDataFailure("POLICY_REJECTED", operation, {
recovery: storagePolicySnapshot.unavailableFallback,
}),
);
return;
}
finishCaller(mapIndexedDbException(request.error, operation));
finishOpeningAttempt(mapIndexedDbException(request.error, operation));
};
request.onsuccess = () => {
const opened = request.result;
void (async () => {
if (callerSettled || disposed) {
if (requestSettled || disposed) {
settleLateRequest();
opened.close();
if (!disposed) {
@@ -861,14 +860,14 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
"INDEXEDDB_MIGRATE",
);
observeResult("INDEXEDDB_MIGRATE", result);
finishCaller(result);
finishOpeningAttempt(result);
return;
}
const binding = await verifyIndexedDbDatasetBinding(
opened,
dependencies.governanceStore,
expectedBinding,
signal,
undefined,
);
settleLateRequest();
if (!binding.ok) {
@@ -876,8 +875,8 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
if (!disposed) {
updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" });
}
if (!callerSettled) {
finishCaller(
if (!requestSettled) {
finishOpeningAttempt(
binding.reason === "ABORTED"
? browserDataFailure("ABORTED", operation)
: binding.reason === "NATIVE_ERROR"
@@ -894,7 +893,7 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
}
return;
}
if (callerSettled || disposed) {
if (requestSettled || disposed) {
opened.close();
if (!disposed) {
updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" });
@@ -915,12 +914,46 @@ export function createIndexedDbRuntime<Value, WireValue, Query>(
appliedMigrations,
);
}
finishCaller(browserDataSuccess(undefined));
finishOpeningAttempt(browserDataSuccess(undefined));
})();
};
});
}
async function open(
signal?: AbortSignal,
): Promise<BrowserDataResult<void>> {
const operation = "INDEXEDDB_OPEN" as const;
if (disposed) return observeResult(operation, unavailable(operation));
if (connection) {
return observeResult(operation, browserDataSuccess(undefined));
}
const cancelled = abortedResult(signal, operation);
if (cancelled) return observeResult(operation, cancelled);
if (!factory) {
return observeResult(
operation,
browserDataFailure("UNSUPPORTED", operation, {
recovery: "ONLINE_ONLY",
}),
);
}
if (!openingPromise) {
updateStatus({
kind: "OPENING",
targetVersion: dependencies.schemaVersion,
});
const generation = {};
activeOpeningGeneration = generation;
const attempt = startOpeningAttempt(factory, generation);
openingPromise = attempt;
}
const result = await waitForOpeningAttempt(openingPromise, signal);
return observeResult(operation, result);
}
function createTransaction(
db: IDBDatabase,
stores: readonly string[],
@@ -33,6 +33,8 @@ export interface OpfsWorkerLike {
type: "message",
listener: (event: MessageEvent<unknown>) => void,
): void;
addFailureEventListener?(listener: (event: Event) => void): void;
removeFailureEventListener?(listener: (event: Event) => void): void;
}
export type OpfsWorkerClientDependencies = Readonly<{
@@ -72,6 +74,15 @@ export function createOpfsWorkerGateway(
const pending = new Map<string, PendingRequest>();
let disposed = false;
const rejectAllPending = (): void => {
for (const request of pending.values()) {
clearTimeout(request.timeout);
request.removeAbortListener();
request.reject(new OpfsRpcError("UNAVAILABLE"));
}
pending.clear();
};
const onMessage = (event: MessageEvent<unknown>): void => {
if (disposed) return;
if (!isWorkerResponse(event.data)) return;
@@ -82,7 +93,15 @@ export function createOpfsWorkerGateway(
request.removeAbortListener();
request.resolve(event.data);
};
const onWorkerFailure = (): void => {
if (disposed) return;
disposed = true;
dependencies.worker.removeEventListener("message", onMessage);
dependencies.worker.removeFailureEventListener?.(onWorkerFailure);
rejectAllPending();
};
dependencies.worker.addEventListener("message", onMessage);
dependencies.worker.addFailureEventListener?.(onWorkerFailure);
async function rpc(
request: OpfsWorkerRequestBody,
@@ -92,6 +111,14 @@ export function createOpfsWorkerGateway(
if (disposed) throw new OpfsRpcError("UNAVAILABLE");
if (signal?.aborted) throw new OpfsRpcError("ABORTED");
const requestId = createRequestId();
if (
typeof requestId !== "string" ||
requestId.length === 0 ||
requestId.length > 128 ||
pending.has(requestId)
) {
throw new OpfsRpcError("UNAVAILABLE");
}
const message = { ...request, requestId } as OpfsWorkerRequest;
return await new Promise<OpfsWorkerResponse>((resolve, reject) => {
@@ -423,12 +450,8 @@ export function createOpfsWorkerGateway(
if (disposed) return;
disposed = true;
dependencies.worker.removeEventListener("message", onMessage);
for (const request of pending.values()) {
clearTimeout(request.timeout);
request.removeAbortListener();
request.reject(new OpfsRpcError("UNAVAILABLE"));
}
pending.clear();
dependencies.worker.removeFailureEventListener?.(onWorkerFailure);
rejectAllPending();
},
});
}
@@ -445,8 +468,24 @@ export function createOwnedOpfsWorkerClient(
type: "module",
name: dependencies.workerName ?? "ca-opfs-byte-store",
});
const workerPort: OpfsWorkerLike = {
postMessage: (message, transfer) =>
worker.postMessage(message, transfer ? [...transfer] : []),
addEventListener: (_type, listener) =>
worker.addEventListener("message", listener),
removeEventListener: (_type, listener) =>
worker.removeEventListener("message", listener),
addFailureEventListener: (listener) => {
worker.addEventListener("error", listener);
worker.addEventListener("messageerror", listener);
},
removeFailureEventListener: (listener) => {
worker.removeEventListener("error", listener);
worker.removeEventListener("messageerror", listener);
},
};
const gateway = createOpfsWorkerGateway({
worker,
worker: workerPort,
policy: dependencies.policy,
createRequestId: dependencies.createRequestId,
});
@@ -1,11 +1,3 @@
import type {
IndexedDbRepositoryPort,
IndexedDbWriteReceipt,
} from "../../application/ports/browser-file-storage/indexeddb-port.ts";
import type {
BrowserDataFailure,
BrowserDataResult,
} from "../../application/ports/browser-file-storage/shared.ts";
import {
WEB_PUSH_LIMITS,
WEB_PUSH_PROTOCOLS,
@@ -33,15 +25,66 @@ export type PushControlReceipt = Readonly<{
revision: number;
}>;
export type PushControlWriteReceipt = Readonly<{
key: string;
revision: number;
replayed: boolean;
}>;
/**
* The generic repository owns IndexedDB connection, migration, transaction,
* timeout, codec and version-change policy. This adapter adds only the
* Web Push authority transition rules on top of its revisioned CAS.
* The failure surface this adapter consumes. `code` is intentionally a plain
* string: the storage taxonomy is owned by whichever runtime backs the store,
* and an unrecognised code maps to the safe default in
* {@link mapRepositoryFailure} rather than failing to compile.
*/
export type PushControlRepository = Pick<
IndexedDbRepositoryPort<PushControlV1, never>,
"open" | "read" | "compareAndSwap" | "remove" | "close"
>;
export type PushControlStoreFailure = Readonly<{
code: string;
retryable: boolean;
}>;
export type PushControlStoreResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: PushControlStoreFailure }>;
/**
* The narrow durable store this capability requires: revisioned
* compare-and-swap over a single key.
*
* It is declared here, structurally, rather than imported from the browser
* file/storage port so the two capabilities stay independently removable. The
* generic IndexedDB repository satisfies it as-is; the composition root is
* where the two are joined, and it owns connection, migration, transaction,
* timeout, codec and version-change policy.
*/
export type PushControlRepository = Readonly<{
open(signal?: AbortSignal): Promise<PushControlStoreResult<void>>;
read(
key: string,
signal?: AbortSignal,
): Promise<
PushControlStoreResult<
Readonly<{ value: PushControlV1; revision: number }> | null
>
>;
compareAndSwap(
input: Readonly<{
key: string;
value: PushControlV1;
expectedRevision: number | null;
idempotencyKey: string;
signal?: AbortSignal;
}>,
): Promise<PushControlStoreResult<PushControlWriteReceipt>>;
remove(
input: Readonly<{
key: string;
expectedRevision: number | null;
idempotencyKey: string;
signal?: AbortSignal;
}>,
): Promise<PushControlStoreResult<PushControlWriteReceipt>>;
close(): void;
}>;
export interface PushAssociationFenceStore {
read(input?: Readonly<{
@@ -505,7 +548,7 @@ export function createPushAssociationFenceStore(
}
async function callRepository<Value>(
call: () => Promise<BrowserDataResult<Value>>,
call: () => Promise<PushControlStoreResult<Value>>,
operation: WebPushOperation,
): Promise<WebPushResult<Value>> {
try {
@@ -519,7 +562,7 @@ async function callRepository<Value>(
}
function mapRepositoryFailure(
failure: BrowserDataFailure,
failure: PushControlStoreFailure,
operation: WebPushOperation,
): WebPushResult<never> {
switch (failure.code) {
@@ -651,8 +694,8 @@ function validRepository(
}
function validWriteReceipt(
value: IndexedDbWriteReceipt,
): value is IndexedDbWriteReceipt {
value: PushControlWriteReceipt,
): value is PushControlWriteReceipt {
return (
Boolean(value) &&
value.key === CONTROL_KEY &&
@@ -0,0 +1,57 @@
import type { ServiceWorkerEventHost } from "./service-worker-runtime.ts";
type NotificationOptions = Parameters<
ServiceWorkerEventHost["registration"]["showNotification"]
>[1];
type MatchAllOptions = Parameters<
ServiceWorkerEventHost["clients"]["matchAll"]
>[0];
/**
* Adapts a native Service Worker global scope onto the structural
* {@link ServiceWorkerEventHost} facade.
*
* The native `showNotification` call lives here because `src/adapters/web-push`
* is the owner of the notification API. The single physical worker entry
* (§17.1) composes the runtime but never touches the native API itself.
*/
export type NativeWorkerScope = Readonly<{
location: Readonly<{ origin: string }>;
registration: Readonly<{
showNotification(title: string, options: unknown): Promise<void>;
}>;
clients: Readonly<{
matchAll(options: unknown): Promise<readonly unknown[]>;
openWindow(url: string): Promise<unknown>;
}>;
// Deliberately loose: a native scope declares a richly overloaded listener
// signature, and this facade only needs to forward the registration.
addEventListener(type: string, listener: never, options?: never): void;
removeEventListener(type: string, listener: never, options?: never): void;
}>;
export function createServiceWorkerScopeHost(
scope: NativeWorkerScope,
): ServiceWorkerEventHost {
return Object.freeze({
origin: scope.location.origin,
registration: Object.freeze({
showNotification: (title: string, options: NotificationOptions) =>
scope.registration.showNotification(title, {
body: options.body,
data: options.data,
requireInteraction: options.requireInteraction,
tag: options.tag,
}),
}),
clients: Object.freeze({
matchAll: (options: MatchAllOptions) => scope.clients.matchAll(options),
openWindow: (url: string) => scope.clients.openWindow(url),
}),
addEventListener: (type, listener) =>
scope.addEventListener(type, listener as never),
removeEventListener: (type, listener) =>
scope.removeEventListener(type, listener as never),
});
}
+9 -1
View File
@@ -91,9 +91,17 @@ export function createApplication(
buildId: release.buildId,
releaseId: release.releaseId,
configSchemaVersion: release.configSchemaVersion,
apiContractVersion: release.apiContractVersion,
...(release.apiContractVersion === undefined
? {}
: { apiContractVersion: release.apiContractVersion }),
...(release.contractSetDigest === undefined
? {}
: { contractSetDigest: release.contractSetDigest }),
});
},
getCapabilitySnapshot() {
return outputPorts.runtimeCapabilities.getSnapshot();
},
});
const recovery = Object.freeze({
+11 -1
View File
@@ -1,7 +1,9 @@
import type { SessionState } from "../auth-session-port.ts";
import type { RuntimeCapabilitySnapshot } from "../runtime-capabilities-port.ts";
import type { StoragePort } from "../storage-port.ts";
export type { SessionState } from "../auth-session-port.ts";
export type { RuntimeCapabilitySnapshot };
/**
* Features add their driving API through module augmentation. The application
@@ -31,7 +33,10 @@ export type ReleaseSummary = Readonly<{
buildId: string;
releaseId: string;
configSchemaVersion: string;
apiContractVersion: string;
/** Legacy V1 scalar; absent once the release manifest is V2. */
apiContractVersion?: string;
/** §5.2 contract set identity for a V2 release manifest. */
contractSetDigest?: string;
}>;
export type ApplicationApi = Readonly<{
@@ -54,6 +59,11 @@ export type ApplicationApi = Readonly<{
}>;
runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>;
/**
* §3.5. The static selection reduced by the runtime overrides. Presentation
* reads capability state here instead of importing the composition root.
*/
getCapabilitySnapshot(): RuntimeCapabilitySnapshot;
}>;
recovery: Readonly<{
recoverChunk(input: Readonly<{
@@ -1,5 +1,6 @@
import type { AuthSessionPort } from "../auth-session-port.ts";
import type { ReleaseInfoPort } from "../release-info-port.ts";
import type { RuntimeCapabilitiesPort } from "../runtime-capabilities-port.ts";
import type { StoragePort } from "../storage-port.ts";
import type { TelemetryPort } from "../telemetry-port.ts";
import type { DiagnosticsPort } from "../diagnostics-port.ts";
@@ -17,5 +18,6 @@ export type ApplicationOutputPorts = Readonly<{
diagnostics: DiagnosticsPort;
telemetry: TelemetryPort;
releaseInfo: ReleaseInfoPort;
runtimeCapabilities: RuntimeCapabilitiesPort;
navigation: Readonly<{ reload(): void }>;
}>;
+8 -1
View File
@@ -4,7 +4,13 @@ export type ReleaseInfo = Readonly<{
buildId: string;
commitSha?: string;
configSchemaVersion: string;
apiContractVersion: string;
/**
* §5.1. Legacy scalar, present only while a V1 release manifest is still
* accepted. A V2 manifest expresses contract identity through
* {@link ReleaseInfo.contractSetDigest}.
*/
apiContractVersion?: string;
contractSetDigest?: string;
assetManifestHash: string;
releaseId: string;
builtAt?: string;
@@ -17,6 +23,7 @@ export type ActiveReleaseInfo = Readonly<
| "buildId"
| "configSchemaVersion"
| "apiContractVersion"
| "contractSetDigest"
| "assetManifestHash"
| "releaseId"
| "routeChunks"
@@ -0,0 +1,12 @@
import type { RuntimeCapabilitySnapshot } from "../../contracts/runtime-capabilities.ts";
export type { RuntimeCapabilitySnapshot };
/**
* §3.5. The application reads capability state; it never resolves it. Only the
* composition root knows the runtime overrides, so the snapshot arrives here
* already reduced to counts and cannot be used to reach a runtime object.
*/
export type RuntimeCapabilitiesPort = Readonly<{
getSnapshot(): RuntimeCapabilitySnapshot;
}>;
+44 -2
View File
@@ -1,6 +1,12 @@
import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
import { createCompositionRoot } from "./composition-root.ts";
import { loadReleaseManifest } from "./load-release-manifest.ts";
import { loadRuntimeConfig } from "./load-runtime-config.ts";
import {
createOptionalRuntimeHost,
type OptionalRuntimeHost,
} from "./optional-runtime-host.ts";
import { createRuntimeAdapters } from "./runtime-adapters.ts";
export type RuntimeCompositionDependencies = Readonly<{
@@ -8,10 +14,16 @@ export type RuntimeCompositionDependencies = Readonly<{
host?: Record<string, unknown>;
}>;
export function createRuntimeComposition(
/**
* §6.7 steps 7 and 14. The static capability selection is compiled against the
* runtime overrides, and the optional host objects are created without any
* start side effect. Nothing observable happens until the first committed React
* effect calls `optional.startAfterMount()`.
*/
export async function createRuntimeComposition(
dependencies: RuntimeCompositionDependencies = {},
) {
return createCompositionRoot({
const root = await createCompositionRoot({
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
loadRelease: (runtime) =>
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
@@ -23,6 +35,36 @@ export function createRuntimeComposition(
host: dependencies.host,
}),
});
const capabilities = resolveRuntimeCapabilities(
INSTALLED_RUNTIME_CAPABILITIES,
root.config.config.CAPABILITY_OVERRIDES,
);
const optional: OptionalRuntimeHost = createOptionalRuntimeHost({
capabilities,
routerBasePath: root.config.build.routerBasePath,
buildId: root.release.buildId,
...(dependencies.host
? {
host: dependencies.host as Parameters<
typeof createOptionalRuntimeHost
>[0]["host"],
}
: {}),
});
return Object.freeze({
...root,
capabilities,
optional,
async dispose(): Promise<void> {
// §20.3. Optional runtime first, then the base infrastructure: the query
// cache is cleared only after realtime and worker admission has closed,
// so a late effect cannot repopulate a cleared cache.
await optional.stop("APPLICATION_SHUTDOWN");
root.infrastructure.dispose();
},
});
}
export type RuntimeComposition = Awaited<
+152 -50
View File
@@ -1,41 +1,77 @@
import { z } from "zod";
import {
verifyContractSet,
type ContractSet,
type ContractSetFailureCode,
} from "../contracts/contract-set.ts";
import type { ContractSetPackage } from "../contracts/contract-set-canonical.ts";
import {
releaseManifestV1ArtifactSchema,
releaseManifestV2ArtifactSchema,
type ReleaseManifestV1Artifact,
type ReleaseManifestV2Artifact,
} from "../contracts/release-artifacts.ts";
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../features/installed-contract-contributions.ts";
import {
BOOT_JSON_POLICIES,
readBoundedBootJson,
type BootLoadFailure,
} from "./read-bounded-boot-json.ts";
import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
export const releaseManifestSchema = z
.object({
schemaVersion: z.literal(1),
appVersion: z.string().min(1),
buildId: z.string().min(1),
commitSha: z.string().min(1),
configSchemaVersion: version,
apiContractVersion: version,
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)),
})
.strict();
/** §5.8. Retained for one compatibility window; carries the removed scalar. */
export const releaseManifestV1Schema = releaseManifestV1ArtifactSchema;
/** §5.2. The frontend build's compiled external contract package set. */
export const releaseManifestV2Schema = releaseManifestV2ArtifactSchema;
export type ReleaseManifestV1 = ReleaseManifestV1Artifact;
export type ReleaseManifestV2 = ReleaseManifestV2Artifact;
/**
* The composition-facing manifest. A V1 document is normalized onto it with a
* null contract set so downstream runtime never branches on schema version.
*/
export type ReleaseManifest = Readonly<{
schemaVersion: 1 | 2;
appVersion: string;
buildId: string;
commitSha: string;
configSchemaVersion: string;
assetManifestHash: string;
releaseId: string;
builtAt: string;
routeChunks: Readonly<Record<string, string>>;
contractSet: ContractSet | null;
legacyApiContractVersion?: string;
}>;
export type ReleaseManifest = z.output<typeof releaseManifestSchema>;
export type ReleaseManifestErrorCode =
| "MANIFEST_BUILD_MISMATCH"
| "MANIFEST_PROTOCOL_PAIR_MISMATCH"
| "MANIFEST_CONFIG_SCHEMA_MISMATCH"
| "MANIFEST_API_CONTRACT_MISMATCH"
| "MANIFEST_RELEASE_MISMATCH"
| "MANIFEST_ASSET_MISMATCH"
| "MANIFEST_FETCH_FAILED"
| "MANIFEST_TIMEOUT"
| "MANIFEST_HTTP_FAILED"
| "MANIFEST_CONTENT_TYPE_INVALID"
| "MANIFEST_BODY_TOO_LARGE"
| "MANIFEST_UTF8_INVALID"
| "MANIFEST_JSON_INVALID"
| "MANIFEST_SCHEMA_INVALID";
| "MANIFEST_SCHEMA_INVALID"
| ContractSetFailureCode;
export type ReleaseManifestFailureKind =
| "BUILD_MISMATCH"
| "PROTOCOL_PAIR_MISMATCH"
| "CONFIG_MISMATCH"
| "API_CONTRACT_MISMATCH"
| "RELEASE_MISMATCH"
| "ASSET_MISMATCH"
| "CONTRACT_SET_MISMATCH"
| "RELEASE_MANIFEST_FAILURE";
export type ReleaseManifestSafe = Readonly<{
kind: ReleaseManifestFailureKind;
code: ReleaseManifestErrorCode;
@@ -55,6 +91,8 @@ function failureKindFor(
switch (code) {
case "MANIFEST_BUILD_MISMATCH":
return "BUILD_MISMATCH";
case "MANIFEST_PROTOCOL_PAIR_MISMATCH":
return "PROTOCOL_PAIR_MISMATCH";
case "MANIFEST_CONFIG_SCHEMA_MISMATCH":
return "CONFIG_MISMATCH";
case "MANIFEST_API_CONTRACT_MISMATCH":
@@ -64,7 +102,9 @@ function failureKindFor(
case "MANIFEST_ASSET_MISMATCH":
return "ASSET_MISMATCH";
default:
return "RELEASE_MANIFEST_FAILURE";
return code.startsWith("CONTRACT_")
? "CONTRACT_SET_MISMATCH"
: "RELEASE_MANIFEST_FAILURE";
}
}
@@ -88,63 +128,111 @@ export class ReleaseManifestError extends Error {
}
}
const READ_FAILURE_CODE: Readonly<
Record<BootLoadFailure, ReleaseManifestErrorCode>
> = Object.freeze({
FETCH_FAILED: "MANIFEST_FETCH_FAILED",
TIMEOUT: "MANIFEST_TIMEOUT",
HTTP_STATUS_INVALID: "MANIFEST_HTTP_FAILED",
CONTENT_TYPE_INVALID: "MANIFEST_CONTENT_TYPE_INVALID",
BODY_TOO_LARGE: "MANIFEST_BODY_TOO_LARGE",
UTF8_INVALID: "MANIFEST_UTF8_INVALID",
JSON_INVALID: "MANIFEST_JSON_INVALID",
SHAPE_INVALID: "MANIFEST_SCHEMA_INVALID",
SECRET_NAME_REJECTED: "MANIFEST_SCHEMA_INVALID",
SCHEMA_INVALID: "MANIFEST_SCHEMA_INVALID",
BUILD_MISMATCH: "MANIFEST_BUILD_MISMATCH",
RELEASE_MISMATCH: "MANIFEST_RELEASE_MISMATCH",
ASSET_MISMATCH: "MANIFEST_ASSET_MISMATCH",
CONTRACT_SET_MISMATCH: "CONTRACT_SET_DIGEST_MISMATCH",
});
export type FetchReleaseManifestOptions = Readonly<{
fetcher?: typeof fetch;
buildId: string;
releaseId?: string;
signal?: AbortSignal;
}>;
/**
* Fetches and validates the active manifest without imposing the current
* build tuple. Chunk recovery uses this no-store view to detect a new release.
* Fetches and validates the active manifest without imposing the current build
* tuple. Chunk recovery uses this no-store view to detect a new release.
*/
export async function fetchReleaseManifest(
url: string,
options: FetchReleaseManifestOptions,
): Promise<Readonly<ReleaseManifest>> {
const fetcher = options.fetcher ?? fetch;
let response: Response;
try {
response = await fetcher(url, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", options);
): Promise<ReleaseManifest> {
const outcome = await readBoundedBootJson(
url,
BOOT_JSON_POLICIES.RELEASE_MANIFEST,
{
...(options.fetcher ? { fetcher: options.fetcher } : {}),
...(options.signal ? { signal: options.signal } : {}),
},
);
if (!outcome.ok) {
throw new ReleaseManifestError(READ_FAILURE_CODE[outcome.failure], options);
}
if (!response.ok) {
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options);
if (outcome.value.schemaVersion === 2) {
const parsed = releaseManifestV2Schema.safeParse(outcome.value);
if (!parsed.success) {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options);
}
return Object.freeze(structuredClone(parsed.data));
}
let raw: unknown;
try {
raw = await response.json();
} catch {
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", options);
}
const parsed = releaseManifestSchema.safeParse(raw);
const parsed = releaseManifestV1Schema.safeParse(outcome.value);
if (!parsed.success) {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options);
}
return Object.freeze(structuredClone(parsed.data));
const { apiContractVersion, ...rest } = structuredClone(parsed.data);
return Object.freeze({
...rest,
contractSet: null,
legacyApiContractVersion: apiContractVersion,
});
}
export type LoadReleaseManifestOptions = Readonly<{
fetcher?: typeof fetch;
expectedAssetManifestHash?: string;
expectedContractSetPackages?: readonly ContractSetPackage[];
signal?: AbortSignal;
}>;
export async function loadReleaseManifest(
runtime: RuntimeConfigLoadResult,
options: LoadReleaseManifestOptions = {},
): Promise<Readonly<ReleaseManifest>> {
): Promise<ReleaseManifest> {
const identity: ReleaseManifestSafeInput = {
buildId: runtime.build.buildId,
...(runtime.config.RELEASE_ID
? { releaseId: runtime.config.RELEASE_ID }
: {}),
};
const manifest = await fetchReleaseManifest(
runtime.config.RELEASE_MANIFEST_URL,
{
fetcher: options.fetcher,
...(options.fetcher ? { fetcher: options.fetcher } : {}),
...(options.signal ? { signal: options.signal } : {}),
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
...(runtime.config.RELEASE_ID
? { releaseId: runtime.config.RELEASE_ID }
: {}),
},
);
const expectedManifestVersion = runtime.configSchema === "V1" ? 1 : 2;
if (manifest.schemaVersion !== expectedManifestVersion) {
throw new ReleaseManifestError(
"MANIFEST_PROTOCOL_PAIR_MISMATCH",
identity,
);
}
// §6.7 steps 5-6, in order: build, config, release, assets, then contractSet.
let mismatchCode: ReleaseManifestErrorCode | null = null;
if (manifest.buildId !== runtime.build.buildId) {
mismatchCode = "MANIFEST_BUILD_MISMATCH";
@@ -164,7 +252,11 @@ export async function loadReleaseManifest(
}
if (
!mismatchCode &&
manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION
runtime.configSchema === "V1" &&
(manifest.legacyApiContractVersion === undefined ||
runtime.config.LEGACY_API_CONTRACT_VERSION === undefined ||
manifest.legacyApiContractVersion !==
runtime.config.LEGACY_API_CONTRACT_VERSION)
) {
mismatchCode = "MANIFEST_API_CONTRACT_MISMATCH";
}
@@ -183,10 +275,20 @@ export async function loadReleaseManifest(
mismatchCode = "MANIFEST_ASSET_MISMATCH";
}
if (mismatchCode) {
throw new ReleaseManifestError(mismatchCode, {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
throw new ReleaseManifestError(mismatchCode, identity);
}
if (manifest.schemaVersion === 2 && manifest.contractSet) {
const verification = await verifyContractSet({
expected:
options.expectedContractSetPackages ??
(EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[]),
manifest: manifest.contractSet,
});
if (!verification.ok) {
throw new ReleaseManifestError(verification.code, identity);
}
}
return manifest;
}
+53 -38
View File
@@ -1,9 +1,19 @@
import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.ts";
import {
BOOT_JSON_POLICIES,
readBoundedBootJson,
type BootLoadFailure,
} from "./read-bounded-boot-json.ts";
import {
validateRuntimeConfig,
type RuntimeConfig,
} from "./runtime-config-schema.ts";
/**
* §6.6. A safe boot error carries the failure kind, the build identity and a
* support reference. It never carries a URL, a response body, a validation
* value, an endpoint hostname or a stack trace.
*/
export type BootConfigSafe = Readonly<{
kind: "BOOT_CONFIG_FAILURE";
code: string;
@@ -43,58 +53,57 @@ export type RuntimeConfigLoadOptions = Readonly<{
fetcher?: typeof fetch;
buildConfig?: ReturnType<typeof getBuildConfig>;
now?: () => number;
signal?: AbortSignal;
}>;
export type RuntimeConfigLoadResult = Readonly<{
config: RuntimeConfig;
configSchema: "V1" | "V2";
build: ReturnType<typeof getBuildConfig>;
validationDurationMs: number;
}>;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
const READ_FAILURE_CODE: Readonly<Record<BootLoadFailure, string>> =
Object.freeze({
FETCH_FAILED: "CONFIG_FETCH_FAILED",
TIMEOUT: "CONFIG_TIMEOUT",
HTTP_STATUS_INVALID: "CONFIG_HTTP_FAILED",
CONTENT_TYPE_INVALID: "CONFIG_CONTENT_TYPE_INVALID",
BODY_TOO_LARGE: "CONFIG_BODY_TOO_LARGE",
UTF8_INVALID: "CONFIG_UTF8_INVALID",
JSON_INVALID: "CONFIG_JSON_INVALID",
SHAPE_INVALID: "CONFIG_SHAPE_INVALID",
SECRET_NAME_REJECTED: "CONFIG_SECRET_NAME_REJECTED",
SCHEMA_INVALID: "CONFIG_SCHEMA_INVALID",
BUILD_MISMATCH: "CONFIG_BUILD_MISMATCH",
RELEASE_MISMATCH: "CONFIG_RELEASE_MISMATCH",
ASSET_MISMATCH: "CONFIG_ASSET_MISMATCH",
CONTRACT_SET_MISMATCH: "CONFIG_CONTRACT_SET_MISMATCH",
});
export async function loadRuntimeConfig(
options: RuntimeConfigLoadOptions = {},
): Promise<RuntimeConfigLoadResult> {
const fetcher = options.fetcher ?? fetch;
const buildConfig = options.buildConfig ?? getBuildConfig();
const now = options.now ?? performance.now.bind(performance);
const outcome = await readBoundedBootJson(
buildConfig.runtimeConfigUrl,
BOOT_JSON_POLICIES.RUNTIME_CONFIG,
{
...(options.fetcher ? { fetcher: options.fetcher } : {}),
...(options.signal ? { signal: options.signal } : {}),
},
);
if (!outcome.ok) {
throw new BootConfigError(READ_FAILURE_CODE[outcome.failure], {
buildId: buildConfig.buildId,
});
}
const startedAt = now();
let response: Response;
try {
response = await fetcher(buildConfig.runtimeConfigUrl, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new BootConfigError("CONFIG_FETCH_FAILED", {
buildId: buildConfig.buildId,
});
}
if (!response.ok) {
throw new BootConfigError("CONFIG_HTTP_FAILED", {
buildId: buildConfig.buildId,
});
}
let rawConfig: unknown;
try {
rawConfig = await response.json();
} catch {
throw new BootConfigError("CONFIG_JSON_INVALID", {
buildId: buildConfig.buildId,
});
}
if (!isRecord(rawConfig)) {
throw new BootConfigError("CONFIG_SHAPE_INVALID", {
buildId: buildConfig.buildId,
});
}
const rawConfig = outcome.value;
try {
assertSafeConfigNames(rawConfig);
@@ -119,7 +128,10 @@ export async function loadRuntimeConfig(
});
}
if (validated.data.BUILD_ID && validated.data.BUILD_ID !== buildConfig.buildId) {
if (
validated.data.BUILD_ID &&
validated.data.BUILD_ID !== buildConfig.buildId
) {
throw new BootConfigError("CONFIG_BUILD_MISMATCH", {
buildId: buildConfig.buildId,
configSchemaVersion: validated.data.CONFIG_SCHEMA_VERSION,
@@ -127,8 +139,11 @@ export async function loadRuntimeConfig(
});
}
// §6.10: the validated snapshot is frozen. Nothing re-reads or mutates it;
// a kill-switch change only takes effect on a new boot.
return Object.freeze({
config: validated.data,
configSchema: validated.schema,
build: buildConfig,
validationDurationMs: now() - startedAt,
});
+1 -1
View File
@@ -25,7 +25,7 @@ async function boot(): Promise<void> {
initializeColorScheme(composition.application.preferences);
root.render(<RuntimeApplication composition={composition} />);
import.meta.hot?.dispose(() => {
composition.infrastructure.dispose();
void composition.dispose();
});
} catch (error: unknown) {
const safe =
+152
View File
@@ -0,0 +1,152 @@
import {
createBrowserLifecycleRuntime,
type BrowserLifecycleRuntime,
} from "../adapters/platform/browser-lifecycle.ts";
import type {
ResolvedRuntimeCapabilities,
RuntimeHealth,
RuntimeStopReason,
} from "../contracts/runtime-capabilities.ts";
import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts";
import { createServiceWorkerRuntimeHost } from "./register-service-worker.ts";
/**
* §3.4 / §20.3. Optional runtime host.
*
* Creating this object has no side effect: no listener, timer, network call,
* IndexedDB open or worker is created until `startAfterMount()` runs from the
* first committed React effect. `stop()` unwinds in reverse order.
*/
export type OptionalRuntimeHost = Readonly<{
readonly realtime: null;
readonly webWorkers: null;
readonly serviceWorker: ServiceWorkerRuntimeHost | null;
readonly offlineCommands: null;
browserLifecycle(): BrowserLifecycleRuntime | null;
health(): Readonly<Record<string, RuntimeHealth>>;
startAfterMount(): Promise<void>;
stop(reason?: RuntimeStopReason): Promise<void>;
}>;
export type OptionalRuntimeHostInput = Readonly<{
capabilities: ResolvedRuntimeCapabilities;
routerBasePath: string;
buildId: string;
/** Explicit host seam for deterministic lifecycle tests and platform shells. */
serviceWorkerHost?: ServiceWorkerRuntimeHost | null;
browserLifecycleHost?: Parameters<typeof createBrowserLifecycleRuntime>[0];
host?: Parameters<typeof createServiceWorkerRuntimeHost>[0]["host"];
blockers?: readonly (() => boolean)[];
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
}>;
export function createOptionalRuntimeHost(
input: OptionalRuntimeHostInput,
): OptionalRuntimeHost {
const { capabilities } = input;
const serviceWorker = Object.hasOwn(input, "serviceWorkerHost")
? (input.serviceWorkerHost ?? null)
: createServiceWorkerRuntimeHost({
capabilities,
routerBasePath: input.routerBasePath,
buildId: input.buildId,
...(input.host ? { host: input.host } : {}),
...(input.blockers ? { blockers: input.blockers } : {}),
...(input.observe ? { observe: input.observe } : {}),
});
let lifecycle: BrowserLifecycleRuntime | null = null;
let started = false;
let startPromise: Promise<void> | null = null;
let stopPromise: Promise<void> | null = null;
let stopRequested = false;
const health: Record<string, RuntimeHealth> = {
realtime: capabilities.realtime.length === 0 ? "DISABLED" : "UNAVAILABLE",
webWorkers: capabilities.webWorkers.length === 0 ? "DISABLED" : "UNAVAILABLE",
serviceWorker: serviceWorker ? "UNAVAILABLE" : "DISABLED",
offlineCommands: capabilities.offlineCommands ? "UNAVAILABLE" : "DISABLED",
};
/**
* §3.4 start order:
* 1. offline foreground browser lifecycle observer
* 2. realtime runtime
* 3. no Web Worker prewarm
* 4. Service Worker active/cleanup controller
*/
async function startAfterMount(): Promise<void> {
// A stopped host is terminal. Its children (notably the Service Worker page
// controller) own one-shot listeners and timers and cannot be resurrected.
if (stopPromise) return stopPromise;
if (startPromise) return startPromise;
startPromise = (async () => {
// 1. The lifecycle observer is the single window listener owner. It is
// only created when something downstream can actually consume it.
if (serviceWorker || capabilities.realtime.length > 0) {
lifecycle = createBrowserLifecycleRuntime(input.browserLifecycleHost);
}
// 2. Realtime stays NOT_SELECTED until a product contribution exists
// (§13.6), so there is nothing to start and nothing to observe.
// 3. Web Workers are created lazily on first task; there is no prewarm.
// 4. Service Worker registration or the exact-owned cleanup action.
if (serviceWorker) {
const outcome = await serviceWorker.start();
// `stop()` may have fenced this generation while start was awaiting a
// browser operation. A late result must never reactivate health.
if (!stopRequested) {
health.serviceWorker =
outcome.kind === "ACTIVE"
? "AVAILABLE"
: outcome.kind === "DISABLED"
? "DISABLED"
: outcome.kind === "INCOMPATIBLE"
? "INCOMPATIBLE"
: "DEGRADED";
}
}
if (!stopRequested) started = true;
})();
return startPromise;
}
async function stop(
reason: RuntimeStopReason = "APPLICATION_SHUTDOWN",
): Promise<void> {
void reason;
stopRequested = true;
if (stopPromise) return stopPromise;
stopPromise = (async () => {
// Serialize teardown behind any in-flight browser registration. Cleanup
// then observes the final acquired resources and unwinds them exactly once.
await startPromise?.catch(() => {});
// Reverse of the start order.
if (serviceWorker && (started || startPromise)) {
await serviceWorker.stop().catch(() => {});
}
if (serviceWorker) {
health.serviceWorker = "DISABLED";
}
lifecycle?.dispose();
lifecycle = null;
started = false;
startPromise = null;
})();
return stopPromise;
}
return Object.freeze({
realtime: null,
webWorkers: null,
serviceWorker,
offlineCommands: null,
browserLifecycle: () => lifecycle,
health: () => Object.freeze({ ...health }),
startAfterMount,
stop,
});
}
+189
View File
@@ -0,0 +1,189 @@
/**
* §6.4–§6.5. The only boot-time JSON reader.
*
* `response.json()` and unbounded `response.text()` are prohibited: a hostile or
* misconfigured origin must not be able to amplify boot memory, and an HTML
* error page must not reach `JSON.parse` as if it were configuration.
*/
export type BootJsonOperation = "RUNTIME_CONFIG" | "RELEASE_MANIFEST";
export interface BootJsonPolicy {
readonly operation: BootJsonOperation;
readonly maximumBytes: number;
readonly totalDeadlineMs: 5_000;
}
export const BOOT_JSON_POLICIES = Object.freeze({
RUNTIME_CONFIG: Object.freeze({
operation: "RUNTIME_CONFIG" as const,
maximumBytes: 65_536,
totalDeadlineMs: 5_000 as const,
}),
RELEASE_MANIFEST: Object.freeze({
operation: "RELEASE_MANIFEST" as const,
maximumBytes: 1_048_576,
totalDeadlineMs: 5_000 as const,
}),
} satisfies Readonly<Record<BootJsonOperation, BootJsonPolicy>>);
export type BootLoadFailure =
| "FETCH_FAILED"
| "TIMEOUT"
| "HTTP_STATUS_INVALID"
| "CONTENT_TYPE_INVALID"
| "BODY_TOO_LARGE"
| "UTF8_INVALID"
| "JSON_INVALID"
| "SHAPE_INVALID"
| "SECRET_NAME_REJECTED"
| "SCHEMA_INVALID"
| "BUILD_MISMATCH"
| "RELEASE_MISMATCH"
| "ASSET_MISMATCH"
| "CONTRACT_SET_MISMATCH";
export type BootJsonOutcome =
| Readonly<{ ok: true; value: Readonly<Record<string, unknown>> }>
| Readonly<{ ok: false; failure: BootLoadFailure }>;
export type ReadBoundedBootJsonOptions = Readonly<{
fetcher?: typeof fetch;
signal?: AbortSignal;
}>;
function isJsonMediaType(headerValue: string | null): boolean {
if (!headerValue) return false;
const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? "";
return essence === "application/json" || essence.endsWith("+json");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export async function readBoundedBootJson(
url: string,
policy: BootJsonPolicy,
options: ReadBoundedBootJsonOptions = {},
): Promise<BootJsonOutcome> {
if (options.signal?.aborted) return fail("FETCH_FAILED");
const fetcher = options.fetcher ?? fetch;
const controller = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, policy.totalDeadlineMs);
const forwardAbort = () => controller.abort();
options.signal?.addEventListener("abort", forwardAbort, { once: true });
try {
let response: Response;
try {
response = await fetcher(url, {
method: "GET",
cache: "no-store",
credentials: "same-origin",
redirect: "error",
referrerPolicy: "no-referrer",
headers: { Accept: "application/json" },
signal: controller.signal,
});
} catch {
return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED");
}
if (response.status !== 200 || response.redirected) {
await discard(response);
return fail("HTTP_STATUS_INVALID");
}
if (!isJsonMediaType(response.headers.get("content-type"))) {
await discard(response);
return fail("CONTENT_TYPE_INVALID");
}
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > policy.maximumBytes) {
await discard(response);
return fail("BODY_TOO_LARGE");
}
const bytes = await readBoundedBytes(response, policy.maximumBytes, controller);
if (bytes === "TOO_LARGE") return fail("BODY_TOO_LARGE");
if (bytes === "STREAM_FAILED") return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED");
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return fail("UTF8_INVALID");
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return fail("JSON_INVALID");
}
if (!isRecord(parsed)) return fail("SHAPE_INVALID");
return Object.freeze({ ok: true as const, value: parsed });
} finally {
clearTimeout(timer);
options.signal?.removeEventListener("abort", forwardAbort);
}
}
async function readBoundedBytes(
response: Response,
maximumBytes: number,
controller: AbortController,
): Promise<Uint8Array | "TOO_LARGE" | "STREAM_FAILED"> {
const body = response.body;
if (!body) {
// A body-less 200 cannot satisfy any boot document.
return new Uint8Array(0);
}
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > maximumBytes) {
await reader.cancel().catch(() => {});
controller.abort();
return "TOO_LARGE";
}
chunks.push(value);
}
} catch {
await reader.cancel().catch(() => {});
return "STREAM_FAILED";
}
const output = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
async function discard(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// Cancelling an already-settled body is not a boot failure.
}
}
function fail(failure: BootLoadFailure): BootJsonOutcome {
return Object.freeze({ ok: false as const, failure });
}
+57
View File
@@ -0,0 +1,57 @@
import type {
ResolvedRuntimeCapabilities,
} from "../contracts/runtime-capabilities.ts";
import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts";
import { createServiceWorkerPageController } from "../adapters/service-worker/service-worker-page-controller.ts";
/**
* §17.5. Composition-side factory. The host object is created without any
* side effect; `start()` is only called from the post-mount runtime starter,
* after Runtime Config, the release manifest and the contract set have all
* validated and React has committed its first render.
*/
export type ServiceWorkerHostInput = Readonly<{
capabilities: ResolvedRuntimeCapabilities;
routerBasePath: string;
buildId: string;
host?: Readonly<{
location?: Pick<Location, "origin">;
navigator?: Pick<Navigator, "serviceWorker">;
caches?: CacheStorage;
document?: Pick<Document, "visibilityState">;
}>;
blockers?: readonly (() => boolean)[];
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
}>;
export function createServiceWorkerRuntimeHost(
input: ServiceWorkerHostInput,
): ServiceWorkerRuntimeHost | null {
const { capabilities } = input;
// §3.6. `null` selection with no disable-cleanup obligation means zero
// registration lookups and zero Cache Storage access: no host at all.
if (!capabilities.serviceWorker && !capabilities.serviceWorkerDisabledCleanup) {
return null;
}
const host =
input.host ??
(globalThis as unknown as NonNullable<ServiceWorkerHostInput["host"]>);
const container = host?.navigator?.serviceWorker;
const origin = host?.location?.origin;
if (!origin) return null;
return createServiceWorkerPageController({
selection: capabilities.serviceWorker,
disabledCleanup: capabilities.serviceWorkerDisabledCleanup,
routerBasePath: input.routerBasePath,
origin,
buildId: input.buildId,
...(container ? { container } : {}),
...(host?.caches ? { caches: host.caches } : {}),
...(input.blockers ? { blockers: input.blockers } : {}),
...(input.observe ? { observe: input.observe } : {}),
});
}
+213 -61
View File
@@ -6,23 +6,32 @@ import {
} from "../adapters/auth/external-session-adapter.ts";
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts";
import { createHttpClient } from "../adapters/http/client.ts";
import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts";
import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts";
import { createTanStackCacheCoordinator } from "../adapters/query-cache/tanstack-cache-coordinator.ts";
import {
createTanStackCacheCoordinator,
type InstalledQueryInvalidationDefinition,
} from "../adapters/query-cache/tanstack-cache-coordinator.ts";
import { createQueryClient } from "../adapters/query-cache/tanstack-query-cache.ts";
import { createServerStateScopeRuntime } from "../adapters/query-cache/server-state-scope-runtime.ts";
import { createConditionalValidatorStore } from "../adapters/query-cache/conditional-validator-store.ts";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
import { createRestProviderProfile } from "../contracts/rest-profiles.ts";
import type { ClockPort } from "../application/ports/clock-port.ts";
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
import { QUERY_REGISTRY } from "../features/installed-feature-contracts.ts";
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
import { describeRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
import {
fetchReleaseManifest,
type ReleaseManifest,
} from "./load-release-manifest.ts";
import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts";
import { createServerStateGenerationStore } from "./server-state-generation-store.ts";
import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../features/installed-contract-contributions.ts";
type HttpClientDependencies = Parameters<typeof createHttpClient>[0];
export type RuntimeHttpContract = Pick<
@@ -47,6 +56,26 @@ export type RuntimeAdaptersContext = Readonly<{
fetcher?: typeof fetch;
}>;
/**
* §5.2. The composition root is where a manifest becomes release info. A V2
* manifest states contract identity as a verified contract set and a V1
* manifest as the legacy scalar; the application layer sees one shape and never
* branches on the schema version to find the identity.
*/
function toReleaseInfo(manifest: Readonly<ReleaseManifest>): ReleaseInfo {
const { contractSet, legacyApiContractVersion, ...rest } =
structuredClone(manifest);
return Object.freeze({
...rest,
...(legacyApiContractVersion === undefined
? {}
: { apiContractVersion: legacyApiContractVersion }),
...(contractSet === null
? {}
: { contractSetDigest: contractSet.setDigest }),
});
}
const EXTERNAL_OWNER_METHODS = Object.freeze([
"readState",
"subscribe",
@@ -155,48 +184,69 @@ export async function createRuntimeAdapters(
});
},
});
const queryClient = createQueryClient({ diagnostics });
const crossContextInvalidation =
createBrowserCrossContextInvalidationFromHost({
...(context.host === undefined ? {} : { host: context.host }),
cacheEpoch: `release.${context.release.releaseId}`,
topics: Object.values(QUERY_REGISTRY).map((definition) =>
Object.freeze({
topic: definition.invalidationTopic,
topicVersion: definition.version,
}),
),
observe(observation) {
if (
observation.outcome !== "FAILED" &&
observation.outcome !== "DEGRADED"
) {
return;
}
diagnostics.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation: observation.operation,
outcome: observation.outcome,
reason: observation.reason,
},
});
},
// The composition root states the registry shape it consumes rather than
// inferring it from whichever features happen to be installed, so a build
// with zero installed features still type-checks.
const queryRegistry: Readonly<
Record<string, InstalledQueryInvalidationDefinition>
> = QUERY_REGISTRY;
const conditionalValidators = createConditionalValidatorStore();
const serverStateGeneration = createServerStateGenerationStore(() => {
const queryClient = createQueryClient({ diagnostics });
const crossContextInvalidation =
createBrowserCrossContextInvalidationFromHost({
...(context.host === undefined ? {} : { host: context.host }),
cacheEpoch: `release.${context.release.releaseId}`,
topics: Object.values(queryRegistry).map((definition) =>
Object.freeze({
topic: definition.invalidationTopic,
topicVersion: definition.version,
}),
),
observe(observation) {
if (
observation.outcome !== "FAILED" &&
observation.outcome !== "DEGRADED"
) {
return;
}
diagnostics.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation: observation.operation,
outcome: observation.outcome,
reason: observation.reason,
},
});
},
});
const queryInvalidation = createTanStackCacheCoordinator({
queryClient,
queryRegistry,
crossContext: crossContextInvalidation,
diagnostics,
});
return Object.freeze({
queryClient,
queryInvalidation,
crossContextStatus: () =>
crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY",
});
const queryInvalidation = createTanStackCacheCoordinator({
queryClient,
queryRegistry: QUERY_REGISTRY,
crossContext: crossContextInvalidation,
diagnostics,
});
const serverStateScope = createServerStateScopeRuntime({
session: authSession,
queryInvalidation,
});
const conditionalValidators = createConditionalValidatorStore();
const unsubscribeConditionalScope = serverStateScope.subscribe(() => {
conditionalValidators.clear();
queryInvalidation: {
resetLocal: () => serverStateGeneration.resetCurrent(),
},
participants: [
{
order: 4,
label: "conditional-validators",
close: () => conditionalValidators.clear(),
},
],
activateNextGeneration: () => serverStateGeneration.activateNext(),
});
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(hostValue(host, "localStorage")),
@@ -207,14 +257,24 @@ export async function createRuntimeAdapters(
});
const releaseInfo = Object.freeze({
async getCurrent() {
return structuredClone(context.release);
return toReleaseInfo(context.release);
},
async refresh() {
return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
fetcher: context.fetcher,
buildId: context.release.buildId,
releaseId: context.release.releaseId,
});
return toReleaseInfo(
await fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
fetcher: context.fetcher,
buildId: context.release.buildId,
releaseId: context.release.releaseId,
}),
);
},
});
const runtimeCapabilities = Object.freeze({
getSnapshot() {
return describeRuntimeCapabilities(
INSTALLED_RUNTIME_CAPABILITIES,
config.CAPABILITY_OVERRIDES,
);
},
});
const navigation = Object.freeze({
@@ -226,18 +286,105 @@ export async function createRuntimeAdapters(
location.reload();
},
});
const contractHttp = createContractHttpExecutor({
baseUrl: config.API_BASE_URL,
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
fetcher: context.fetcher,
async attachCredentials(operation) {
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
const state = authSession.getState();
if (state === "integration-failed") {
return Object.freeze({ kind: "UNAVAILABLE" as const });
}
if (state !== "authenticated") {
return Object.freeze({ kind: "UNAUTHENTICATED" as const });
}
try {
const patch = await authSession.credentialPatch({
origin: new URL(config.API_BASE_URL).origin,
method: operation.method,
operationId: operation.operationId,
});
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
return Object.freeze({
kind: "READY" as const,
headers: patch.headers,
credentials: "omit" as const,
});
} catch {
return Object.freeze({ kind: "UNAVAILABLE" as const });
}
},
observe(observation) {
try {
diagnostics.record({
level:
observation.outcome === "SUCCESS" ? "info" : "warn",
eventId: "http.request.completed",
context: {
operation_id: observation.diagnosticsOperation,
outcome: observation.outcome,
attempts: observation.attempts,
certainty: observation.certainty,
},
});
} catch {
// Diagnostics cannot change a contract execution outcome.
}
},
});
let contractExecutionSequence = 0;
const contractOperations = Object.freeze({
async execute(
operationId: string,
input: unknown,
executionContext: Readonly<{ signal?: AbortSignal }> = {},
) {
const operation =
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
if (!operation) {
return Object.freeze({
kind: "CONTRACT_VIOLATION" as const,
effect: "NOT_STARTED" as const,
violation: Object.freeze({
kind: "FINAL_REQUEST_INVARIANT_FAILED" as const,
operation: "REQUEST" as const,
}),
});
}
contractExecutionSequence += 1;
const intentId = `http-intent-${contractExecutionSequence}`;
const isCommand = operation.contract.commandEffect !== null;
const requiresKey = operation.contract.retrySemantics === "KEYED";
const outcome = await contractHttp.execute(operation, input, {
scope: serverStateScope.getSnapshot(),
...(executionContext.signal === undefined
? {}
: { signal: executionContext.signal }),
...(isCommand
? {
intent: Object.freeze({
intentId,
startedBy: "USER" as const,
...(requiresKey
? { idempotencyKey: `http-key-${contractExecutionSequence}` }
: {}),
}),
}
: {}),
});
if (outcome.kind === "UNAUTHENTICATED") {
authSession.onUnauthenticated();
}
return outcome;
},
});
const featureInputs = createInstalledFeatureInputs({
createHttpClient: (contract) =>
createRuntimeHttpClient(
{
runtime: context.runtime,
authSession,
fetcher: context.fetcher,
diagnostics,
telemetry,
},
contract,
),
contractOperations,
});
return Object.freeze({
@@ -247,20 +394,25 @@ export async function createRuntimeAdapters(
diagnostics,
telemetry,
releaseInfo,
runtimeCapabilities,
navigation,
}),
infrastructure: Object.freeze({
queryClient,
queryInvalidation,
get queryClient() {
return serverStateGeneration.getSnapshot().queryClient;
},
get queryInvalidation() {
return serverStateGeneration.getSnapshot().queryInvalidation;
},
serverStateGeneration,
serverStateScope,
conditionalValidators,
crossContextInvalidationStatus: () =>
crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY",
serverStateGeneration.getSnapshot().crossContextStatus(),
dispose() {
unsubscribeConditionalScope();
conditionalValidators.clear();
serverStateScope.dispose();
queryInvalidation.dispose();
serverStateGeneration.dispose();
},
}),
featureInputs,
+35 -20
View File
@@ -1,9 +1,7 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { StrictMode, useEffect } from "react";
import { ApplicationProvider } from "../presentation/providers/application-provider.tsx";
import { QueryInvalidationProvider } from "../presentation/adapters/query/query-invalidation-provider.tsx";
import { ServerStateScopeProvider } from "../presentation/adapters/query/server-state-scope-provider.tsx";
import { ServerStateGenerationProvider } from "../presentation/adapters/query/server-state-generation-provider.tsx";
import { AppRouter } from "../presentation/routes/app-router.tsx";
import type { RuntimeComposition } from "./create-runtime-composition.ts";
@@ -11,27 +9,44 @@ import type { RuntimeComposition } from "./create-runtime-composition.ts";
* Production provider tree. Tests import this component so the validated
* composition is proven against the same provider order used by main.
*/
/**
* §6.7 step 16 / §17.5. Optional runtime starts from the first committed
* effect, never during render and never during composition. StrictMode double
* invocation is safe: `startAfterMount` returns the same in-flight promise.
*/
function PostMountRuntimeStarter({
composition,
}: Readonly<{ composition: RuntimeComposition }>) {
useEffect(() => {
void composition.optional.startAfterMount();
}, [composition]);
return null;
}
export function RuntimeApplication({
composition,
}: Readonly<{ composition: RuntimeComposition }>) {
return (
<StrictMode>
<QueryClientProvider client={composition.infrastructure.queryClient}>
<ServerStateScopeProvider
runtime={composition.infrastructure.serverStateScope}
>
<QueryInvalidationProvider
coordinator={composition.infrastructure.queryInvalidation}
>
<ApplicationProvider application={composition.application}>
<AppRouter
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
/>
</ApplicationProvider>
</QueryInvalidationProvider>
</ServerStateScopeProvider>
</QueryClientProvider>
<ServerStateGenerationProvider
store={composition.infrastructure.serverStateGeneration}
scope={composition.infrastructure.serverStateScope}
transitionFallback={
<div
aria-busy="true"
className="state-surface state-surface--loading"
data-scope-state="scope-transition"
/>
}
>
<ApplicationProvider application={composition.application}>
<PostMountRuntimeStarter composition={composition} />
<AppRouter
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
/>
</ApplicationProvider>
</ServerStateGenerationProvider>
</StrictMode>
);
}
+123 -64
View File
@@ -1,79 +1,138 @@
import { z } from "zod";
import {
runtimeConfigV1ArtifactSchema,
runtimeConfigV2ArtifactSchema,
type CapabilityOverrideArtifact,
type RuntimeConfigV1Artifact,
type RuntimeConfigV2Artifact,
} from "../contracts/release-artifacts.ts";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
export { isValidReleaseManifestUrl } from "../contracts/release-artifacts.ts";
export const runtimeConfigSchema = z
.object({
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"]),
CONFIG_SCHEMA_VERSION: version,
API_CONTRACT_VERSION: version,
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(),
})
.strict()
.superRefine((config, context) => {
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
context.addIssue({
code: "custom",
path: ["TELEMETRY_ENDPOINT"],
message: "required when telemetry is enabled",
});
}
export type CapabilityOverrides = CapabilityOverrideArtifact;
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
if (!local && config.AUTH_MODE === "demo") {
context.addIssue({
code: "custom",
path: ["AUTH_MODE"],
message: "demo authentication is limited to local environments",
});
}
const endpointEntries = [
["API_BASE_URL", config.API_BASE_URL],
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
] as const;
/**
* §6.1. Runtime Config V2 is deployment and browser operational setting only.
* `API_CONTRACT_VERSION` is gone: a scalar cannot describe a multi-package
* contract set, and Release Manifest V2 `contractSet` owns that meaning.
*/
export const runtimeConfigV2Schema = runtimeConfigV2ArtifactSchema;
for (const [key, value] of endpointEntries) {
if (value && !local && new URL(value).protocol !== "https:") {
context.addIssue({
code: "custom",
path: [key],
message: "HTTPS is required outside local environments",
});
}
}
});
/**
* §5.8 / §24.6 RC-2. The V1 reader is retained for one compatibility window so
* a release never swaps source shape, manifest shape and runtime behaviour at
* the same time. Only a V1 document may carry the scalar contract version.
*/
export const runtimeConfigV1Schema = runtimeConfigV1ArtifactSchema;
export type RuntimeConfigV2 = RuntimeConfigV2Artifact;
export type RuntimeConfigV1 = RuntimeConfigV1Artifact;
/**
* The composition-facing shape. V1 documents are normalized onto it so the rest
* of the runtime never branches on config schema version.
*/
export type RuntimeConfig = Readonly<{
APP_ENV: RuntimeConfigV2["APP_ENV"];
API_BASE_URL: string;
REQUEST_TIMEOUT_MS: number;
MAX_RETRY_ATTEMPTS: number;
TELEMETRY_ENABLED: boolean;
TELEMETRY_ENDPOINT?: string;
AUTH_MODE: RuntimeConfigV2["AUTH_MODE"];
CONFIG_SCHEMA_VERSION: string;
RELEASE_MANIFEST_URL: string;
RELEASE_ID?: string;
BUILD_ID?: string;
CAPABILITY_OVERRIDES: CapabilityOverrides;
/** Present only while a V1 document is still accepted. */
LEGACY_API_CONTRACT_VERSION?: string;
}>;
export type RuntimeConfig = z.output<typeof runtimeConfigSchema>;
export type RuntimeConfigValidation =
| Readonly<{ success: true; data: RuntimeConfig }>
| Readonly<{ success: true; data: RuntimeConfig; schema: "V1" | "V2" }>
| Readonly<{
success: false;
issues: readonly Readonly<{ path: string; code: string }>[];
}>;
export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
const result = runtimeConfigSchema.safeParse(value);
const DEFAULT_OVERRIDES: CapabilityOverrides = Object.freeze({
REALTIME: "DEFAULT" as const,
WEB_WORKER: "DEFAULT" as const,
SERVICE_WORKER: "DEFAULT" as const,
OFFLINE_COMMANDS: "DEFAULT" as const,
});
if (!result.success) {
return {
success: false,
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
function canonicalUrl(value: string): string {
return new URL(value).href;
}
export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
const declared =
value && typeof value === "object"
? (value as Record<string, unknown>).CONFIG_SCHEMA_VERSION
: undefined;
// §5.8: no precedence between V1 and V2. The declared version selects exactly
// one parser, and a V2 document carrying the removed scalar is rejected.
if (declared !== "1" && declared !== "2.0") {
return Object.freeze({
success: false as const,
issues: Object.freeze([
Object.freeze({
path: "CONFIG_SCHEMA_VERSION",
code: "unsupported_value",
}),
]),
});
}
return {
success: true,
data: structuredClone(result.data),
};
const isV2 = declared === "2.0";
const result = isV2
? runtimeConfigV2Schema.safeParse(value)
: runtimeConfigV1Schema.safeParse(value);
if (!result.success) {
return Object.freeze({
success: false as const,
issues: Object.freeze(
result.error.issues.map((issue) =>
Object.freeze({ path: issue.path.join("."), code: issue.code }),
),
),
});
}
const parsed = result.data;
const normalized: RuntimeConfig = Object.freeze({
APP_ENV: parsed.APP_ENV,
API_BASE_URL: canonicalUrl(parsed.API_BASE_URL),
REQUEST_TIMEOUT_MS: parsed.REQUEST_TIMEOUT_MS,
MAX_RETRY_ATTEMPTS: parsed.MAX_RETRY_ATTEMPTS,
TELEMETRY_ENABLED: parsed.TELEMETRY_ENABLED,
...(parsed.TELEMETRY_ENDPOINT
? { TELEMETRY_ENDPOINT: canonicalUrl(parsed.TELEMETRY_ENDPOINT) }
: {}),
AUTH_MODE: parsed.AUTH_MODE,
CONFIG_SCHEMA_VERSION: parsed.CONFIG_SCHEMA_VERSION,
RELEASE_MANIFEST_URL: parsed.RELEASE_MANIFEST_URL,
...(parsed.RELEASE_ID ? { RELEASE_ID: parsed.RELEASE_ID } : {}),
...(parsed.BUILD_ID ? { BUILD_ID: parsed.BUILD_ID } : {}),
CAPABILITY_OVERRIDES: Object.freeze({
...(isV2
? (parsed as RuntimeConfigV2).CAPABILITY_OVERRIDES
: DEFAULT_OVERRIDES),
}),
...(isV2
? {}
: {
LEGACY_API_CONTRACT_VERSION: (parsed as RuntimeConfigV1)
.API_CONTRACT_VERSION,
}),
});
return Object.freeze({
success: true as const,
data: normalized,
schema: isV2 ? ("V2" as const) : ("V1" as const),
});
}
@@ -0,0 +1,74 @@
import type { QueryClient } from "@tanstack/react-query";
import type { CrossContextInvalidationStatus } from "../adapters/cross-context-invalidation/index.ts";
import type { QueryInvalidationCoordinator } from "../contracts/query-invalidation.ts";
export type ServerStateGenerationResources = Readonly<{
queryClient: QueryClient;
queryInvalidation: QueryInvalidationCoordinator;
crossContextStatus(): CrossContextInvalidationStatus;
}>;
export type ServerStateGenerationSnapshot =
ServerStateGenerationResources & Readonly<{ generation: number }>;
export type ServerStateGenerationStore = Readonly<{
getSnapshot(): ServerStateGenerationSnapshot;
subscribe(listener: () => void): () => void;
resetCurrent(): Promise<void>;
activateNext(): void;
dispose(): void;
}>;
export function createServerStateGenerationStore(
createResources: (generation: number) => ServerStateGenerationResources,
): ServerStateGenerationStore {
const listeners = new Set<() => void>();
let disposed = false;
let current = snapshot(1, createResources(1));
function publish(): void {
for (const listener of [...listeners]) {
try {
listener();
} catch {
// Provider defects cannot change generation ownership.
}
}
}
return Object.freeze({
getSnapshot: () => current,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async resetCurrent() {
if (disposed) throw new TypeError("Server-state generations are disposed.");
await current.queryInvalidation.resetLocal();
},
activateNext() {
if (disposed) throw new TypeError("Server-state generations are disposed.");
const previous = current;
previous.queryInvalidation.dispose();
previous.queryClient.clear();
const generation = previous.generation + 1;
current = snapshot(generation, createResources(generation));
publish();
},
dispose() {
if (disposed) return;
disposed = true;
listeners.clear();
current.queryInvalidation.dispose();
current.queryClient.clear();
},
});
}
function snapshot(
generation: number,
resources: ServerStateGenerationResources,
): ServerStateGenerationSnapshot {
return Object.freeze({ generation, ...resources });
}
+111
View File
@@ -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)))}`;
}
+129
View File
@@ -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 });
}
+43 -4
View File
@@ -1,4 +1,27 @@
const forbiddenConfigName = /(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i;
/**
* §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<{
@@ -21,8 +44,9 @@ export const ENV_REGISTRY = Object.freeze({
TELEMETRY_ENDPOINT: runtime("public-sensitive", false, null),
AUTH_MODE: runtime("public", true, "external"),
CONFIG_SCHEMA_VERSION: runtime("public", true, null),
API_CONTRACT_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(
@@ -43,14 +67,29 @@ function runtime(
export function assertSafeConfigNames(
config: Readonly<Record<string, unknown>>,
depth = 0,
): void {
for (const name of Object.keys(config)) {
if (forbiddenConfigName.test(name)) {
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;
+18 -2
View File
@@ -228,6 +228,13 @@ 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;
@@ -238,6 +245,7 @@ export type AppFailure = Readonly<{
requestId?: string;
traceId?: string;
retryAfterMs?: number;
effect?: FailureEffectCertainty;
validationIssues?: readonly ValidationIssue[];
userMessageKey: string;
action: ErrorAction;
@@ -257,6 +265,7 @@ export type FailureDetails = Readonly<{
requestId?: string;
traceId?: string;
retryAfterMs?: number;
effect?: FailureEffectCertainty;
validationIssues?: readonly ValidationIssue[];
causeClass?: string;
}>;
@@ -271,7 +280,10 @@ export function createFailure(
return Object.freeze({
kind: definition.kind,
code: typeof details.code === "string" ? details.code : definition.kind,
retryable: definition.defaultRetryable,
retryable:
details.effect === "MAYBE_APPLIED"
? false
: definition.defaultRetryable,
operationId,
attemptCount: Math.max(1, attempt + 1),
...(Number.isInteger(details.httpStatus)
@@ -282,6 +294,7 @@ export function createFailure(
...(typeof details.retryAfterMs === "number"
? { retryAfterMs: details.retryAfterMs }
: {}),
...(details.effect === undefined ? {} : { effect: details.effect }),
...(Array.isArray(details.validationIssues)
? {
validationIssues: Object.freeze(
@@ -304,7 +317,10 @@ export function createFailure(
? { causeClass: details.causeClass }
: {}),
userMessageKey: definition.userMessageKey,
action: definition.action,
action:
details.effect === "MAYBE_APPLIED"
? "contact-support"
: definition.action,
});
}
+582
View File
@@ -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),
});
}
+154
View File
@@ -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]),
});
}
+144
View File
@@ -19,6 +19,150 @@ export function defineQueryInvalidationTopic(
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: string;
}>;
export interface InvalidationRegistry {
readonly topics: readonly string[];
readonly namespaces: readonly string[];
readonly edges: readonly InvalidationRegistryEdge[];
}
export type InvalidationRegistryIndex = Readonly<{
namespacesForTopic: ReadonlyMap<string, readonly string[]>;
topicsForNamespace: ReadonlyMap<string, readonly string[]>;
}>;
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 Set<string>();
for (const namespace of registry.namespaces) {
assertId(namespace, "namespace");
if (namespaces.has(namespace)) {
throw new TypeError(`Duplicate invalidation namespace: ${namespace}`);
}
namespaces.add(namespace);
}
const namespacesForTopic = new Map<string, string[]>();
const topicsForNamespace = new Map<string, string[]>();
const seenEdges = new Map<string, Set<string>>();
for (const edge of registry.edges) {
if (!topics.has(edge.topicId) || !namespaces.has(edge.namespace)) {
throw new TypeError("Invalidation edge references an unknown endpoint.");
}
const seenNamespaces = seenEdges.get(edge.topicId) ?? new Set<string>();
if (seenNamespaces.has(edge.namespace)) {
throw new TypeError("Duplicate invalidation edge.");
}
seenNamespaces.add(edge.namespace);
seenEdges.set(edge.topicId, seenNamespaces);
const fanOut = namespacesForTopic.get(edge.topicId) ?? [];
fanOut.push(edge.namespace);
if (fanOut.length > bounds.maxTopicFanOut) {
throw new TypeError(`Invalidation topic fan-out exceeded: ${edge.topicId}`);
}
namespacesForTopic.set(edge.topicId, fanOut);
const fanIn = topicsForNamespace.get(edge.namespace) ?? [];
fanIn.push(edge.topicId);
if (fanIn.length > bounds.maxNamespaceFanIn) {
throw new TypeError(
`Invalidation namespace fan-in exceeded: ${edge.namespace}`,
);
}
topicsForNamespace.set(edge.namespace, fanIn);
}
for (const topic of topics) {
if (!namespacesForTopic.has(topic)) {
throw new TypeError(`Orphan invalidation topic: ${topic}`);
}
}
for (const namespace of namespaces) {
if (!topicsForNamespace.has(namespace)) {
throw new TypeError(`Orphan invalidation namespace: ${namespace}`);
}
}
return Object.freeze({
namespacesForTopic: new Map(
[...namespacesForTopic].map(([key, value]) => [
key,
Object.freeze([...value]) as readonly string[],
]),
),
topicsForNamespace: new Map(
[...topicsForNamespace].map(([key, value]) => [
key,
Object.freeze([...value]) as readonly string[],
]),
),
});
}
export type QueryMutationLease = Readonly<{
/**
* Releases one local mutation fence. Remote hints coalesced while the fence
+268
View File
@@ -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,
});
}
+20 -5
View File
@@ -12,7 +12,12 @@ export const RELEASE_TOKEN_REGISTRY = Object.freeze({
apiContractVersion: token(
"apiContractVersion",
"frontend/backend agreement",
"schema compatibility",
"legacy V1 scalar; superseded by contractSetDigest",
),
contractSetDigest: token(
"contractSetDigest",
"compiled external contract package set",
"release coherence for multi-package contracts",
),
assetManifestHash: token(
"assetManifestHash",
@@ -37,23 +42,33 @@ export function compareReleaseToRuntime(
release: Readonly<{
buildId: string;
configSchemaVersion: string;
apiContractVersion: string;
apiContractVersion?: string;
assetManifestHash: string;
releaseId: string;
}>,
runtimeConfig: Readonly<{
BUILD_ID: string;
CONFIG_SCHEMA_VERSION: string;
API_CONTRACT_VERSION: string;
/**
* §5.1. Removed from Runtime Config V2. When neither side declares it there
* is nothing to disagree about: contract identity is verified by the
* Release Manifest V2 `contractSet` check instead.
*/
API_CONTRACT_VERSION?: string;
RELEASE_ID: string;
}>,
) {
const declaredContractVersion =
runtimeConfig.API_CONTRACT_VERSION ?? release.apiContractVersion ?? "0";
return verifyCompatibilityTuple({
frontend: release,
frontend: {
...release,
apiContractVersion: release.apiContractVersion ?? declaredContractVersion,
},
runtime: {
buildId: runtimeConfig.BUILD_ID,
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
apiContractVersion: declaredContractVersion,
assetManifestHash: release.assetManifestHash,
releaseId: runtimeConfig.RELEASE_ID,
},
+6
View File
@@ -17,6 +17,12 @@ export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({
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",
+14 -1
View File
@@ -3,7 +3,7 @@ export type RouteDefinition = Readonly<{
path: string;
paramsSchema: string | null;
searchSchema: string | null;
access: "public" | "session-required" | "integration-defined";
access: "public" | "session-required";
loadingSurface: string;
errorSurface: string;
chunkId: string;
@@ -30,6 +30,19 @@ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({
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",
+266
View File
@@ -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],
}),
),
);
}
+22
View File
@@ -4,11 +4,33 @@ 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;
}>;
+171 -5
View File
@@ -3,6 +3,17 @@ import type { QueryInvalidationTopic } from "./query-invalidation.ts";
import type { RuntimeIdentityBinding } from "./query-keys.ts";
import type { CacheScopeSnapshot } from "./server-state-scope.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;
@@ -15,12 +26,128 @@ export type ServerStateProfile = Readonly<{
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>>;
}>;
@@ -31,7 +158,8 @@ export type QueryDefinition<Input, Value> = Readonly<{
namespace: string;
namespaceVersion: number;
operationId: string;
profile: ServerStateProfile;
profileId: ServerStateProfileId;
measureResult(value: Value): QueryResultMeasure;
execute(
input: Input,
context: Readonly<{ signal: AbortSignal }>,
@@ -43,9 +171,16 @@ export function bindQuery<Input, Value>(
input: Input,
scope: CacheScopeSnapshot,
): BoundQuery<Value> {
if (typeof definition.measureResult !== "function") {
throw new TypeError(
`Query definition requires measureResult: ${definition.definitionId}`,
);
}
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: Object.freeze([
"query",
1,
@@ -53,23 +188,54 @@ export function bindQuery<Input, Value>(
definition.namespace,
definition.namespaceVersion,
definition.definitionVersion,
identity,
identity.token,
]),
profile: definition.profile,
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;
owner: string;
duplicatePolicy: "JOIN_IDENTICAL" | "REJECT_DUPLICATE" | "ALLOW_INDEPENDENT";
duplicatePolicy: MutationDuplicatePolicy;
scope: CacheScopeSnapshot;
execute(input: Input): Promise<Result<Value>>;
execute(
input: Input,
context: Readonly<{ signal: AbortSignal }>,
): Promise<Result<Value>>;
invalidate: readonly QueryInvalidationTopic[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
+152
View File
@@ -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>;
}
+139
View File
@@ -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]);
}
@@ -0,0 +1,27 @@
import {
composeContractContributions,
type InstalledContractContribution,
type InstalledContractPackageIdentity,
} from "../contracts/external-contract-runtime.ts";
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
/**
* §4.8. Static contract selection SSOT.
*
* A product feature adds exactly one entry per service package here and imports
* the generated package only from
* `src/features/<feature>/contracts/<service>-contract-contribution.ts`.
*/
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze([REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
INSTALLED_CONTRACT_CONTRIBUTIONS,
);
/**
* §5.4. `TEMPLATE_FIXTURE` provenance is filtered out before the contract set
* is canonicalized, so a fixture can never influence the release digest.
*/
export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageIdentity[] =
COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages;
@@ -0,0 +1,49 @@
import type { InstalledOfflineCommandContribution } from "../contracts/offline-command.ts";
import type {
InstalledRealtimeContribution,
InstalledRuntimeCapabilities,
} from "../contracts/runtime-capabilities.ts";
import {
validateRealtimeContributions,
} from "../contracts/runtime-capabilities.ts";
import type { InstalledServiceWorkerSelection } from "../contracts/service-worker.ts";
import {
validateWebWorkerContributions,
type InstalledWebWorkerContribution,
} from "../contracts/web-worker.ts";
/**
* §3.5. Static optional capability selection SSOT.
*
* Adding an entry here is what installs a capability. Runtime Config can only
* disable what this file already selected, and no dynamic import path is ever
* built from a configuration string.
*
* Current template state, matching the §25 stop conditions:
*
* - realtime: `NOT_SELECTED`. The RT-01..RT-04 common runtime exists and is
* independently verified, but no product contribution supplies an exact
* endpoint, event descriptor and recovery mode (§13.6).
* - webWorkers: `NOT_SELECTED`. No profiled CPU-bound task with an owner
* exists, so no production worker entry is created (§16.2).
* - serviceWorker: `null`. Switching to `ACTIVE` also enables the two-pass
* build in `scripts/build-frontend.ts`.
* - offlineCommands: `NOT_SELECTED`. No external package declares a `KEYED`
* operation with a recovery descriptor (§19.3).
*/
const REALTIME: readonly InstalledRealtimeContribution[] = Object.freeze([]);
const WEB_WORKERS: readonly InstalledWebWorkerContribution[] = Object.freeze([]);
const SERVICE_WORKER: InstalledServiceWorkerSelection | null = null;
const OFFLINE_COMMANDS: InstalledOfflineCommandContribution | null = null;
export const INSTALLED_RUNTIME_CAPABILITIES: InstalledRuntimeCapabilities =
Object.freeze({
realtime: validateRealtimeContributions(REALTIME),
webWorkers: validateWebWorkerContributions(WEB_WORKERS),
serviceWorker: SERVICE_WORKER,
offlineCommands: OFFLINE_COMMANDS,
});
@@ -1,65 +1,174 @@
import type { ApiOperation } from "../../../contracts/api-operations.ts";
import type { Result } from "../../../application/result.ts";
import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts";
import type { FailureEffectCertainty } from "../../../contracts/errors.ts";
import {
createFailure,
kindForStatus,
} from "../../../contracts/errors.ts";
import type { HttpExecutionOutcome } from "../../../adapters/http/http-execution-v3.ts";
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
import {
REFERENCE_FEATURE_CONTRACT,
REFERENCE_FEATURE_ID,
} from "../contracts/reference-feature-contract.ts";
import {
mapWithBoundaryRegistry,
type MappingResult,
} from "../../../contracts/boundary-mapper.ts";
import { validateWithRuntimeSchemaRegistry } from "../../../contracts/schema-registry.ts";
import { mapReferenceOperation } from "../contracts/reference-mapper.ts";
import {
createReferenceHttpGateway,
type RawReferenceHttpExecutor,
type ReferenceHttpRequest,
type ReferenceOperationId,
} from "./reference-http-gateway.ts";
type HttpContract = Readonly<{
getOperation(operationId: string): ApiOperation;
validatePayload(schemaId: string, value: unknown): ReturnType<
typeof validateWithRuntimeSchemaRegistry
>;
validateRequest(schemaId: string, value: unknown): ReturnType<
typeof validateWithRuntimeSchemaRegistry
>;
validatePath(schemaId: string, value: unknown): ReturnType<
typeof validateWithRuntimeSchemaRegistry
>;
mapPayload(operationId: string, payload: unknown): MappingResult<unknown>;
export type InstalledContractOperationExecutor = Readonly<{
execute(
operationId: string,
input: unknown,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<HttpExecutionOutcome<unknown, unknown>>;
}>;
type HttpExecutor = RawReferenceHttpExecutor;
/**
* The installed feature consumes the composed external-contract operation
* registry through one descriptor-driven executor. Legacy ApiOperation/schema
* registries are intentionally absent from this production composition seam.
*/
export function createReferenceFeatureInstalledInput(context: Readonly<{
createHttpClient(contract: HttpContract): HttpExecutor;
contractOperations: InstalledContractOperationExecutor;
}>) {
const operations =
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<Record<string, ApiOperation>>;
const schemas = REFERENCE_FEATURE_CONTRACT.runtimeSchemas;
const mappers = REFERENCE_FEATURE_CONTRACT.mappers;
const validate = (schemaId: string, value: unknown) =>
validateWithRuntimeSchemaRegistry(schemaId, value, schemas);
const rawHttp = context.createHttpClient({
getOperation(operationId) {
const operation = operations[operationId];
if (!operation) {
throw new Error(`Unknown reference operation: ${operationId}`);
}
return operation;
},
validatePayload: validate,
validateRequest: validate,
validatePath: validate,
mapPayload(operationId, payload) {
const operation = operations[operationId];
if (!operation?.mapperId) {
return { ok: false, code: "MAPPING_INVARIANT_REJECTED" };
}
return mapWithBoundaryRegistry(operation.mapperId, payload, mappers);
const rawHttp: RawReferenceHttpExecutor = Object.freeze({
async execute(request) {
const operationId = request.operationId;
const input = inputFor(request);
const signal = "signal" in request ? request.signal : undefined;
const outcome = await context.contractOperations.execute(
operationId,
input,
signal === undefined ? {} : { signal },
);
return projectExecutionOutcome(operationId, outcome);
},
});
return Object.freeze({
featureId: REFERENCE_FEATURE_ID,
input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)),
});
}
function inputFor(
request: ReferenceHttpRequest<ReferenceOperationId>,
): unknown {
switch (request.operationId) {
case "LIST_REFERENCE_RESOURCES":
return request.searchParams;
case "CREATE_REFERENCE_RESOURCE":
return request.body;
case "GET_REFERENCE_RESOURCE":
return request.pathParams;
}
}
function projectExecutionOutcome(
operationId: ReferenceOperationId,
outcome: HttpExecutionOutcome<unknown, unknown>,
): Result<unknown, ApiFailure> {
switch (outcome.kind) {
case "SUCCESS": {
const mapped = mapReferenceOperation(operationId, outcome.value);
return mapped.ok
? Object.freeze({ ok: true as const, value: mapped.value })
: failure(
"MAPPING_CONTRACT_VIOLATION",
operationId,
mapped.code,
{ effect: outcome.effect },
);
}
case "PROBLEM":
return failure(
kindForStatus(outcome.metadata.status),
operationId,
"CONTRACT_PROBLEM",
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
);
case "UNAUTHENTICATED":
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
effect: outcome.effect,
});
case "FORBIDDEN":
return failure("FORBIDDEN", operationId, "FORBIDDEN", {
effect: outcome.effect,
});
case "RATE_LIMITED":
return failure("RATE_LIMITED", operationId, "RATE_LIMITED", {
...(outcome.retryAfterMs === undefined
? {}
: { retryAfterMs: outcome.retryAfterMs }),
effect: outcome.effect,
});
case "CANCELLED":
return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", {
effect: outcome.effect,
});
case "TRANSPORT_FAILURE":
return failure(
outcome.failure.kind === "TIMEOUT"
? "REQUEST_TIMEOUT"
: outcome.failure.kind === "ABORTED_BY_SCOPE"
? "SCOPE_GENERATION_CHANGED"
: "NETWORK_UNREACHABLE",
operationId,
outcome.failure.kind,
{ effect: outcome.effect },
);
case "CONTRACT_VIOLATION":
return failure(
failureKindForViolation(outcome.violation.kind),
operationId,
outcome.violation.kind,
{ effect: outcome.effect },
);
}
}
function failureKindForViolation(
violation: Extract<
HttpExecutionOutcome<unknown, unknown>,
{ kind: "CONTRACT_VIOLATION" }
>["violation"]["kind"],
): FailureKind {
switch (violation) {
case "CONTENT_TYPE_MISMATCH":
return "CONTENT_TYPE_MISMATCH";
case "RESPONSE_TOO_LARGE":
return "RESPONSE_BODY_LIMIT";
case "UTF8_INVALID":
case "JSON_INVALID":
return "MALFORMED_JSON";
case "MAPPING_CONTRACT_VIOLATION":
return "MAPPING_CONTRACT_VIOLATION";
case "SCOPE_FENCED":
return "SCOPE_GENERATION_CHANGED";
case "SUCCESS_SCHEMA_INVALID":
case "PROBLEM_SCHEMA_INVALID":
case "VALIDATOR_RUNTIME_FAILURE":
return "SCHEMA_MISMATCH";
default:
return "ENVELOPE_MISMATCH";
}
}
function failure(
kind: FailureKind,
operationId: string,
code: string,
details: Readonly<{
httpStatus?: number;
retryAfterMs?: number;
effect?: FailureEffectCertainty;
}> = {},
): Result<never, ApiFailure> {
return Object.freeze({
ok: false as const,
error: createFailure(kind, operationId, 0, { code, ...details }),
});
}
@@ -25,6 +25,7 @@ type ReferenceOperationMap = Readonly<{
operationId: "CREATE_REFERENCE_RESOURCE";
routeId: "REFERENCE_RESOURCE_LIST";
body: ReferenceCreateCommand;
signal?: AbortSignal;
}>;
value: ReferenceResource;
}>;
@@ -71,11 +72,15 @@ export function createReferenceHttpGateway(
});
return projectListResult(result);
},
async create(command: ReferenceCreateCommand) {
async create(
command: ReferenceCreateCommand,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
body: command,
signal: context?.signal,
});
return projectResourceResult("CREATE_REFERENCE_RESOURCE", result);
},
@@ -26,6 +26,7 @@ export type ReferenceFeatureInput = Readonly<{
): Promise<ReferenceResult<readonly ReferenceResourceView[]>>;
createResource(
command: ReferenceCreateCommand,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
getResource(
resourceId: string,
@@ -46,6 +47,7 @@ export type ReferenceGateway = Readonly<{
): Promise<ReferenceResult<readonly ReferenceResource[]>>;
create(
command: ReferenceCreateCommand,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<ReferenceResource>>;
get(
resourceId: string,
@@ -66,8 +68,8 @@ export function createReferenceFeatureInput(
}
: result;
},
async createResource(command) {
const result = await gateway.create(command);
async createResource(command, context) {
const result = await gateway.create(command, context);
return result.ok
? { ok: true as const, value: toReferenceView(result.value) }
: result;
@@ -0,0 +1,255 @@
import { z } from "zod";
import type {
CommandEffectDescriptor,
HttpRequestProjection,
InstalledContractContribution,
InstalledHttpContract,
RuntimeValidator,
} from "../../../contracts/external-contract-runtime.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
} from "./reference-schemas.ts";
/**
* §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference
* HTTP vertical executable as deterministic template data and is excluded from
* `contractSet`. A product feature MUST instead pin an external package and
* import it from its own `contracts/<service>-contract-contribution.ts`.
*/
function zodValidator<T>(
schemaId: string,
schema: z.ZodType<T>,
): RuntimeValidator<T> {
return Object.freeze({
schemaId,
safeParse(value: unknown) {
const result = schema.safeParse(value);
if (result.success) {
return Object.freeze({
success: true as const,
data: structuredClone(result.data),
});
}
return Object.freeze({
success: false as const,
issues: Object.freeze(
result.error.issues.map((issue) =>
Object.freeze({
path: Object.freeze(
issue.path.map((segment): string | number =>
typeof segment === "number" ? segment : String(segment),
),
),
code: String(issue.code),
}),
),
),
});
},
});
}
const referenceResourceDto = z
.object({
id: z.string().min(1).max(120),
name: z.string().min(1).max(240),
createdAt: z.string().min(1).optional(),
})
.strip();
const problemSchema = z
.object({
type: z.string().min(1).max(512),
title: z.string().min(1).max(240),
status: z.int().min(100).max(599),
code: z.string().min(1).max(120).optional(),
})
.strip();
export type ReferenceProblem = z.output<typeof problemSchema>;
const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema);
const createCommandSchema = z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict();
/**
* Fixture-side classifier standing in for the package-provided pure bounded
* classifier. A status the fixture does not describe stays `MAYBE_APPLIED`.
*/
const CREATE_EFFECT: CommandEffectDescriptor<ReferenceProblem> = Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) {
if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED";
return "MAYBE_APPLIED";
},
});
const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
z.output<typeof referenceResourceListQuerySchema>,
readonly z.output<typeof referenceResourceDto>[],
ReferenceProblem
> = Object.freeze({
contract: Object.freeze({
operationId: "LIST_REFERENCE_RESOURCES",
method: "GET" as const,
pathTemplate: "/api/reference-resources",
inputValidator: zodValidator(
"ReferenceResourceListQuery",
referenceResourceListQuerySchema,
),
outputValidator: zodValidator(
"ReferenceResourceListPayload",
z.array(referenceResourceDto).max(100),
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: z.output<typeof referenceResourceListQuerySchema>) {
const entries: (readonly [string, string])[] = [];
if (input.cursor !== undefined) entries.push(["cursor", input.cursor]);
entries.push(["limit", String(input.limit)]);
for (const tag of input.tags ?? []) entries.push(["tags", tag]);
return Object.freeze({
pathValues: Object.freeze({}),
queryEntries: Object.freeze(entries),
body: null,
});
},
}),
frontend: Object.freeze({
policyId: "REFERENCE_LIST_V1",
requestByteLimit: 0,
responseByteLimit: 262_144,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.list",
}),
});
const GET_REFERENCE_RESOURCE: InstalledHttpContract<
z.output<typeof referenceResourceParamsSchema>,
z.output<typeof referenceResourceDto>,
ReferenceProblem
> = Object.freeze({
contract: Object.freeze({
operationId: "GET_REFERENCE_RESOURCE",
method: "GET" as const,
pathTemplate: "/api/reference-resources/{resourceId}",
inputValidator: zodValidator(
"ReferenceResourceParams",
referenceResourceParamsSchema,
),
outputValidator: zodValidator(
"ReferenceResourcePayload",
referenceResourceDto,
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: z.output<typeof referenceResourceParamsSchema>) {
return Object.freeze({
pathValues: Object.freeze({ resourceId: input.resourceId }),
queryEntries: Object.freeze([]),
body: null,
});
},
}),
frontend: Object.freeze({
policyId: "REFERENCE_DETAIL_V1",
requestByteLimit: 0,
responseByteLimit: 32_768,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.detail",
}),
});
const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
z.output<typeof createCommandSchema>,
z.output<typeof referenceResourceDto>,
ReferenceProblem
> = Object.freeze({
contract: Object.freeze({
operationId: "CREATE_REFERENCE_RESOURCE",
method: "POST" as const,
pathTemplate: "/api/reference-resources",
inputValidator: zodValidator(
"CreateReferenceResourceCommand",
createCommandSchema,
),
outputValidator: zodValidator(
"ReferenceResourcePayload",
referenceResourceDto,
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200, 201]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "KEYED" as const,
requestBody: "JSON" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: Object.freeze({
mode: "IDEMPOTENCY_REPLAY" as const,
operationIdentityField: "idempotencyKey",
}),
commandEffect: CREATE_EFFECT,
projectRequest(input: z.output<typeof createCommandSchema>) {
return Object.freeze({
pathValues: Object.freeze({}),
queryEntries: Object.freeze([]),
body: Object.freeze({ ...input }),
});
},
}),
frontend: Object.freeze({
policyId: "REFERENCE_CREATE_V1",
requestByteLimit: 32_768,
responseByteLimit: 32_768,
totalDeadlineMs: 10_000,
/**
* §8.3. A KEYED command has no automatic retry after a dispatched attempt
* lost its response; the fixture therefore declares a zero budget rather
* than relying on a runtime special case.
*/
retryBudget: 0 as const,
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.create",
}),
});
export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution =
Object.freeze({
contributionId: "reference-feature-http-v1",
featureId: REFERENCE_FEATURE_ID,
source: Object.freeze({
kind: "TEMPLATE_FIXTURE" as const,
fixtureId: "REFERENCE_FEATURE_V1" as const,
revision: 1 as const,
}),
http: Object.freeze([
LIST_REFERENCE_RESOURCES,
GET_REFERENCE_RESOURCE,
CREATE_REFERENCE_RESOURCE,
]) as readonly InstalledHttpContract<unknown, unknown, unknown>[],
events: Object.freeze([]),
});
@@ -84,7 +84,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
path: "/examples/reference-resources",
paramsSchema: null,
searchSchema: "ReferenceResourceListQuery",
access: "integration-defined",
access: "session-required",
loadingSurface: "reference-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-reference-resources",
@@ -97,7 +97,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
path: "/examples/reference-resources/:resourceId",
paramsSchema: "ReferenceResourceParams",
searchSchema: null,
access: "integration-defined",
access: "session-required",
loadingSurface: "reference-resource-detail",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-detail",
@@ -110,7 +110,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
path: "/examples/reference-resources/new",
paramsSchema: null,
searchSchema: null,
access: "integration-defined",
access: "session-required",
loadingSurface: "reference-resource-form",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-form",
@@ -123,7 +123,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
path: "/examples/reference-resources/status",
paramsSchema: null,
searchSchema: null,
access: "integration-defined",
access: "session-required",
loadingSurface: "reference-resource-status",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-status",
@@ -16,22 +16,38 @@ import type {
import type { ReferenceResourceView } from "../contracts/reference-mapper.ts";
import {
bindQuery,
defineServerStateProfile,
type BoundMutation,
type QueryResultMeasure,
} from "../../../contracts/server-state.ts";
import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx";
const REFERENCE_READ_PROFILE = defineServerStateProfile({
profileId: "reference-resource-read-v1",
staleTimeMs: 30_000,
gcTimeMs: 300_000,
refetchOnMount: true,
refetchOnFocus: true,
refetchOnReconnect: true,
retryOwner: "TRANSPORT",
maxResultItems: 100,
maxEstimatedResultBytes: 262_144,
});
const UTF8 = new TextEncoder();
/**
* §10.4. Feature-owned measurement over the mapped application value. There is
* no generic fallback: bounded string bytes plus fixed primitive width plus a
* small per-item overhead, never `JSON.stringify` or a recursive walker.
*/
function measureResourceView(view: ReferenceResourceView): QueryResultMeasure {
return {
itemCount: 1,
estimatedBytes:
UTF8.encode(view.resourceId).byteLength +
UTF8.encode(view.title).byteLength +
UTF8.encode(view.createdAt ?? "").byteLength +
32,
};
}
function measureResourceList(
views: readonly ReferenceResourceView[],
): QueryResultMeasure {
let estimatedBytes = 16;
for (const view of views) {
estimatedBytes += measureResourceView(view).estimatedBytes;
}
return { itemCount: views.length, estimatedBytes };
}
export function useReferenceFeatureInput(): ReferenceFeatureInput {
return useApplication().features.get(REFERENCE_FEATURE_ID);
@@ -49,7 +65,8 @@ export function useReferenceDetail(resourceId: string) {
namespace: "reference-resource",
namespaceVersion: 1,
operationId: "GET_REFERENCE_RESOURCE",
profile: REFERENCE_READ_PROFILE,
profileId: "DETAIL_STANDARD",
measureResult: measureResourceView,
execute: (selectedResourceId: string, { signal }) =>
input.getResource(selectedResourceId, { signal }),
},
@@ -71,7 +88,7 @@ export function useReferenceCreate() {
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "JOIN_IDENTICAL",
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute: input.createResource,
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
@@ -93,7 +110,8 @@ export function useReferenceFeature() {
namespace: "reference-resource",
namespaceVersion: 1,
operationId: "LIST_REFERENCE_RESOURCES",
profile: REFERENCE_READ_PROFILE,
profileId: "LIST_STANDARD",
measureResult: measureResourceList,
execute: (selectedFilters: ReferenceListFilters, { signal }) =>
input.listResources(selectedFilters, { signal }),
},
@@ -106,7 +124,7 @@ export function useReferenceFeature() {
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "JOIN_IDENTICAL",
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute: input.createResource,
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
@@ -21,9 +21,11 @@ import {
type AppFailure,
} from "../../../contracts/errors.ts";
import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
import type {
BoundMutation,
BoundQuery,
import {
admitQueryResult,
type BoundMutation,
type BoundQuery,
type MutationDuplicatePolicy,
} from "../../../contracts/server-state.ts";
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx";
@@ -64,6 +66,8 @@ export function useApplicationQuery<Value>(
const profile = "profile" in options ? options.profile : undefined;
const scope = "scope" in options ? options.scope : undefined;
const identity = "identity" in options ? options.identity : undefined;
const measureResult =
"measureResult" in options ? options.measureResult : undefined;
const queryDefinitionId =
"definitionId" in options ? options.definitionId : "APPLICATION_QUERY";
const [staleFailure, setStaleFailure] = useState(false);
@@ -105,22 +109,22 @@ export function useApplicationQuery<Value>(
);
}
if (result.ok) {
if (
profile &&
!isAdmissibleResult(
if (profile && measureResult) {
const admission = admitQueryResult(
measureResult,
result.value,
profile.maxResultItems,
profile.maxEstimatedResultBytes,
)
) {
throw new ApplicationQueryError(
createFailure(
"RESULT_LIMIT_EXCEEDED",
"APPLICATION_QUERY",
0,
{ code: "RESULT_ADMISSION_LIMIT_EXCEEDED" },
),
profile,
);
if (!admission.ok) {
throw new ApplicationQueryError(
createFailure(
"RESULT_LIMIT_EXCEEDED",
queryDefinitionId,
0,
{ code: admission.code },
),
);
}
}
return result.value;
}
@@ -175,26 +179,34 @@ export function useApplicationQuery<Value>(
});
}
export function useApplicationMutation<Input, Value>(
options:
| BoundMutation<Input, Value>
| Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
invalidate?: readonly QueryInvalidationTopic[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>,
): Readonly<{
type LegacyMutationOptions<Input, Value> = Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
duplicatePolicy?: MutationDuplicatePolicy;
invalidate?: readonly QueryInvalidationTopic[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>;
type ApplicationMutationController<Input, Value> = Readonly<{
state: AsyncState;
submit(input: Input): Promise<ApplicationResult<Value>>;
resolveConflict(): Promise<void>;
}> {
}>;
export function useApplicationMutation<Input, Value>(
options: BoundMutation<Input, Value>,
): ApplicationMutationController<Input, Value>;
export function useApplicationMutation<Input, Value>(
options: LegacyMutationOptions<Input, Value>,
): ApplicationMutationController<Input, Value>;
export function useApplicationMutation<Input, Value>(
options: BoundMutation<Input, Value> | LegacyMutationOptions<Input, Value>,
): ApplicationMutationController<Input, Value> {
const queryClient = useQueryClient();
const invalidationCoordinator = useQueryInvalidationCoordinator();
const { execute } = options;
const invalidate = useMemo(
() => options.invalidate ?? [],
[options.invalidate],
@@ -204,7 +216,7 @@ export function useApplicationMutation<Input, Value>(
const definitionId =
"definitionId" in options ? options.definitionId : "LEGACY_MUTATION";
const duplicatePolicy =
"duplicatePolicy" in options ? options.duplicatePolicy : "JOIN_IDENTICAL";
"duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE";
const [conflict, setConflict] = useState<AppFailure | null>(null);
const scope = "scope" in options ? options.scope : undefined;
const mutation = useMutation<Value, ApplicationQueryError, Input>({
@@ -220,14 +232,21 @@ export function useApplicationMutation<Input, Value>(
),
);
}
const result = await execute(input);
const result =
"scope" in options
? await options.execute(input, { signal: options.scope.signal })
: await options.execute(input);
if (scope && !scope.isCurrent()) {
const effect =
!result.ok && result.error.effect !== undefined
? result.error.effect
: "MAYBE_APPLIED";
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_CHANGED" },
{ code: "MUTATION_SCOPE_CHANGED", effect },
),
);
}
@@ -276,7 +295,7 @@ export function useApplicationMutation<Input, Value>(
identityLease?.release();
return active;
}
if (active && duplicatePolicy === "REJECT_DUPLICATE") {
if (active && duplicatePolicy === "REJECT_WHILE_ACTIVE") {
identityLease?.release();
return Promise.resolve({
ok: false,
@@ -379,7 +398,7 @@ export function useApplicationMutation<Input, Value>(
mutationExecutions(queryClient).delete(identity);
}
});
if (duplicatePolicy !== "ALLOW_INDEPENDENT") {
if (duplicatePolicy !== "ALLOW_PARALLEL") {
mutationExecutions(queryClient).set(identity, pending);
}
return pending;
@@ -445,36 +464,3 @@ function optimisticLayers(
OPTIMISTIC_LAYER_RUNTIMES.set(queryClient, created);
return created;
}
function isAdmissibleResult(
value: unknown,
maxItems: number,
maxBytes: number,
): boolean {
try {
const seen = new WeakSet<object>();
let items = 0;
const visit = (candidate: unknown): boolean => {
if (candidate === null || ["string", "number", "boolean"].includes(typeof candidate)) {
return true;
}
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return false;
seen.add(candidate);
if (Array.isArray(candidate)) {
items += candidate.length;
return items <= maxItems && candidate.every(visit);
}
const prototype = Object.getPrototypeOf(candidate);
return (
(prototype === Object.prototype || prototype === null) &&
Object.values(candidate).every(visit)
);
};
return (
visit(value) &&
new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes
);
} catch {
return false;
}
}
@@ -1,6 +1,7 @@
import { hashKey, type QueryClient } from "@tanstack/react-query";
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts";
export type OptimisticLayerLease = Readonly<{
commit(): void;
@@ -88,11 +89,31 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
} else if (entry.scope !== scope) {
return null;
}
// §11.5. Overflow falls back to pessimistic execution; an existing
// layer is never silently evicted to make room for a new one.
if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) {
if (entry.layers.length === 0) entries.delete(key);
return null;
}
const layer: Layer = {
id: nextId++,
status: "pending",
apply: (value) => update(value, input),
};
let projected: unknown;
try {
projected = update(entry.base, input);
} catch {
if (entry.layers.length === 0) entries.delete(key);
return null;
}
if (
estimateLayerBytes(projected) >
OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes
) {
if (entry.layers.length === 0) entries.delete(key);
return null;
}
entry.layers.push(layer);
project(key, entry);
let settled = false;
@@ -120,3 +141,36 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
},
});
}
const encoder = new TextEncoder();
/**
* A local, bounded estimate of one optimistic projection. This is not the
* §10.4 query result measurement: it only decides whether a rollback snapshot
* stays inside the layer budget, and it stops as soon as the budget is passed.
*/
function estimateLayerBytes(value: unknown): number {
let total = 0;
const stack: unknown[] = [value];
let visited = 0;
while (stack.length > 0) {
if (visited++ > 4_096) return Number.POSITIVE_INFINITY;
if (total > OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes) return total;
const current = stack.pop();
if (typeof current === "string") {
total += encoder.encode(current).byteLength;
} else if (typeof current === "number" || typeof current === "boolean") {
total += 8;
} else if (Array.isArray(current)) {
total += 8;
for (const item of current) stack.push(item);
} else if (current && typeof current === "object") {
total += 8;
for (const [key, item] of Object.entries(current)) {
total += encoder.encode(key).byteLength;
stack.push(item);
}
}
}
return total;
}
@@ -0,0 +1,52 @@
import {
QueryClientProvider,
type QueryClient,
} from "@tanstack/react-query";
import { type ReactNode, useSyncExternalStore } from "react";
import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts";
import type { ServerStateScopeRuntime } from "../../../contracts/server-state-scope.ts";
import { QueryInvalidationProvider } from "./query-invalidation-provider.tsx";
import { ServerStateScopeProvider } from "./server-state-scope-provider.tsx";
export type ServerStateGenerationSource = Readonly<{
getSnapshot(): Readonly<{
generation: number;
queryClient: QueryClient;
queryInvalidation: QueryInvalidationCoordinator;
}>;
subscribe(listener: () => void): () => void;
}>;
export function ServerStateGenerationProvider({
store,
scope,
children,
transitionFallback,
}: Readonly<{
store: ServerStateGenerationSource;
scope: ServerStateScopeRuntime;
children: ReactNode;
transitionFallback?: ReactNode;
}>) {
const generation = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
);
return (
<QueryClientProvider
key={generation.generation}
client={generation.queryClient}
>
<ServerStateScopeProvider
runtime={scope}
transitionFallback={transitionFallback}
>
<QueryInvalidationProvider coordinator={generation.queryInvalidation}>
{children}
</QueryInvalidationProvider>
</ServerStateScopeProvider>
</QueryClientProvider>
);
}
@@ -16,13 +16,27 @@ const ServerStateScopeContext =
export function ServerStateScopeProvider({
runtime,
children,
transitionFallback = null,
}: Readonly<{
runtime: ServerStateScopeRuntime;
children: ReactNode;
transitionFallback?: ReactNode;
}>) {
const phase = useSyncExternalStore(
runtime.subscribe,
runtime.getPhase,
runtime.getPhase,
);
const content =
phase === "READY"
? children
: phase === "DISPOSED"
? null
: transitionFallback;
return (
<ServerStateScopeContext.Provider value={runtime}>
{children}
{content}
</ServerStateScopeContext.Provider>
);
}
@@ -0,0 +1,484 @@
import { useEffect, useState } from "react";
import { HTTP_EXECUTION_CEILINGS } from "../../contracts/external-contract-runtime.ts";
import { SERVER_STATE_PROFILES } from "../../contracts/server-state.ts";
import {
COMPOSED_CONTRACT_CONTRIBUTIONS,
EXPECTED_CONTRACT_SET_PACKAGES,
} from "../../features/installed-contract-contributions.ts";
import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts";
import type {
RuntimeCapabilityId,
RuntimeCapabilityStatus,
} from "../../contracts/runtime-capabilities.ts";
import {
Badge,
Card,
DataTable,
EmptySurface,
PageHeader,
type DataTableColumn,
} from "../design-system/index.ts";
import { useApplication } from "../providers/application-provider.tsx";
/**
* Every number and row on this page is read from an installed registry at
* render time. Nothing is transcribed by hand, so deleting a feature removes
* its rows and the page keeps describing what the repository actually is.
*/
function kilobytes(bytes: number): string {
if (bytes === 0) return "없음";
if (bytes >= 1_048_576) return `${bytes / 1_048_576} MiB`;
return `${bytes / 1024} KiB`;
}
function seconds(milliseconds: number): string {
return milliseconds < 1000
? `${milliseconds}ms`
: `${milliseconds / 1000}`;
}
function Metric({
label,
value,
hint,
}: Readonly<{ label: string; value: string; hint?: string }>) {
return (
<div className="platform-metric">
<dt>{label}</dt>
<dd>
<span className="platform-metric__value">{value}</span>
{hint ? <span className="platform-metric__hint">{hint}</span> : null}
</dd>
</div>
);
}
type RouteRow = (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY];
const ROUTE_COLUMNS: readonly DataTableColumn<RouteRow>[] = Object.freeze([
{
id: "routeId",
header: "라우트",
cell: (row) => <code>{row.routeId}</code>,
},
{ id: "path", header: "경로", cell: (row) => <code>{row.path}</code> },
{
id: "access",
header: "접근",
cell: (row) => (
<Badge variant={row.access === "public" ? "success" : "warning"}>
{row.access}
</Badge>
),
},
{
id: "schemas",
header: "입력 스키마",
cell: (row) =>
[row.paramsSchema, row.searchSchema].filter(Boolean).join(" · ") || "없음",
},
{
id: "chunkId",
header: "청크",
cell: (row) => <code>{row.chunkId}</code>,
},
]);
type OperationRow = Readonly<{
operationId: string;
method: string;
pathTemplate: string;
retrySemantics: string;
retryBudget: number;
totalDeadlineMs: number;
requestByteLimit: number;
responseByteLimit: number;
effect: string;
recovery: string;
}>;
const OPERATION_COLUMNS: readonly DataTableColumn<OperationRow>[] =
Object.freeze([
{
id: "operationId",
header: "오퍼레이션",
cell: (row) => (
<>
<code>{row.operationId}</code>
<span className="platform-operation__path">
{row.method} {row.pathTemplate}
</span>
</>
),
},
{
id: "retry",
header: "재시도",
cell: (row) => (
<>
<Badge variant={row.retrySemantics === "SAFE" ? "success" : "warning"}>
{row.retrySemantics}
</Badge>
<span className="platform-operation__path">
{row.retryBudget}
</span>
</>
),
},
{
id: "effect",
header: "효과 확정성",
cell: (row) => row.effect,
},
{
id: "recovery",
header: "복구",
cell: (row) => row.recovery,
},
{
id: "budget",
header: "예산",
cell: (row) => (
<>
<span className="platform-operation__path">
{kilobytes(row.requestByteLimit)} · {" "}
{kilobytes(row.responseByteLimit)}
</span>
<span className="platform-operation__path">
{seconds(row.totalDeadlineMs)}
</span>
</>
),
},
]);
type ProfileRow = (typeof SERVER_STATE_PROFILES)[keyof typeof SERVER_STATE_PROFILES];
const PROFILE_COLUMNS: readonly DataTableColumn<ProfileRow>[] = Object.freeze([
{
id: "profileId",
header: "프로파일",
cell: (row) => <code>{row.profileId}</code>,
},
{ id: "stale", header: "stale", cell: (row) => seconds(row.staleTimeMs) },
{ id: "gc", header: "gc", cell: (row) => seconds(row.gcTimeMs) },
{
id: "refetch",
header: "재조회",
cell: (row) =>
[
row.refetchOnMount === "always"
? "mount(always)"
: row.refetchOnMount && "mount",
row.refetchOnFocus && "focus",
row.refetchOnReconnect && "reconnect",
]
.filter(Boolean)
.join(" · "),
},
{
id: "budget",
header: "결과 예산",
cell: (row) =>
`${row.maxResultItems}건 · ${kilobytes(row.maxEstimatedResultBytes)}`,
},
]);
const CAPABILITY_COPY: Readonly<
Record<RuntimeCapabilityId, Readonly<{ label: string; description: string }>>
> = Object.freeze({
REALTIME: Object.freeze({
label: "실시간 수신",
description:
"WebSocket, SSE, 경계 폴링 런타임은 구현되어 있습니다. 제품 기여물이 엔드포인트와 이벤트 서술자를 제공해야 설치됩니다.",
}),
WEB_WORKER: Object.freeze({
label: "웹 워커",
description:
"워커 실행 계약과 전용 타입 프로젝트가 준비되어 있습니다. 프로파일링으로 확인된 CPU 작업이 있어야 설치됩니다.",
}),
SERVICE_WORKER: Object.freeze({
label: "서비스 워커",
description:
"참조 런타임과 두 단계 빌드가 준비되어 있습니다. 설치하면 검증된 정적 자산 캐시와 등록 해제 경로가 함께 켜집니다.",
}),
OFFLINE_COMMANDS: Object.freeze({
label: "오프라인 명령",
description:
"명령 큐 상태 기계가 준비되어 있습니다. 복구 서술자를 가진 KEYED 오퍼레이션이 있어야 설치됩니다.",
}),
});
/**
* A capability that was never selected and one an operator switched off look
* identical if both are reported as "off". The snapshot separates them, and so
* does this badge.
*/
function capabilityBadge(
status: RuntimeCapabilityStatus,
): Readonly<{ text: string; variant: "success" | "warning" | "neutral" }> {
if (status.selected === 0) return { text: "미선택", variant: "neutral" };
if (status.active === 0) {
return { text: "운영자가 비활성화함", variant: "warning" };
}
return { text: `활성 (${status.active})`, variant: "success" };
}
function buildOperationRows(): readonly OperationRow[] {
return Object.freeze(
[...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map(
(installed) => {
const { contract, frontend } = installed;
return Object.freeze({
operationId: contract.operationId,
method: contract.method,
pathTemplate: contract.pathTemplate,
retrySemantics: contract.retrySemantics,
retryBudget: frontend.retryBudget,
totalDeadlineMs: frontend.totalDeadlineMs,
requestByteLimit: frontend.requestByteLimit,
responseByteLimit: frontend.responseByteLimit,
effect:
contract.commandEffect === null
? "해당 없음"
: contract.commandEffect.successEffect,
recovery:
contract.commandRecovery === null
? "해당 없음"
: contract.commandRecovery.mode,
});
},
),
);
}
export default function PlatformOverviewPage() {
const { runtime } = useApplication();
const [release, setRelease] = useState<
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
>(null);
useEffect(() => {
let active = true;
void runtime.getReleaseSummary().then((summary) => {
if (active) setRelease(summary);
});
return () => {
active = false;
};
}, [runtime]);
const routes = Object.values(ROUTE_REGISTRY);
const operations = buildOperationRows();
const capabilities = runtime.getCapabilitySnapshot();
const activeCapabilityCount = capabilities.filter(
(status) => status.active > 0,
).length;
const fixtureContributions =
COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
).length;
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="플랫폼 구성"
description="이 화면의 모든 값은 설치된 레지스트리에서 렌더 시점에 읽습니다. 손으로 옮겨 적은 숫자가 없으므로 코드가 바뀌면 이 화면도 함께 바뀝니다."
/>
<section
className="gallery-section"
aria-labelledby="platform-release-title"
>
<header className="gallery-section__header">
<h2 id="platform-release-title"> </h2>
<p>
.
</p>
</header>
<dl className="platform-metric-grid" aria-live="polite">
{release ? (
<>
<Metric label="빌드" value={release.buildId} />
<Metric label="릴리스" value={release.releaseId} />
<Metric
label="설정 스키마"
value={release.configSchemaVersion}
hint={
release.apiContractVersion
? `레거시 계약 ${release.apiContractVersion}`
: "계약 집합 사용"
}
/>
<Metric
label="계약 집합 다이제스트"
value={
release.contractSetDigest
? `${release.contractSetDigest.slice(0, 20)}`
: "없음"
}
hint={
release.contractSetDigest
? "릴리스 매니페스트 V2"
: "릴리스 매니페스트 V1"
}
/>
</>
) : (
<Metric label="상태" value="런타임 정보를 확인하고 있습니다." />
)}
</dl>
</section>
<section
className="gallery-section"
aria-labelledby="platform-summary-title"
>
<header className="gallery-section__header">
<h2 id="platform-summary-title"> </h2>
<p> .</p>
</header>
<dl className="platform-metric-grid">
<Metric label="라우트" value={`${routes.length}`} />
<Metric label="HTTP 오퍼레이션" value={`${operations.length}`} />
<Metric
label="외부 계약 패키지"
value={`${EXPECTED_CONTRACT_SET_PACKAGES.length}`}
hint={`템플릿 픽스처 ${fixtureContributions}`}
/>
<Metric
label="선택적 런타임 능력"
value={`${activeCapabilityCount} / ${capabilities.length}`}
hint="런타임 오버라이드 반영"
/>
</dl>
</section>
<section
className="gallery-section"
aria-labelledby="platform-routes-title"
>
<header className="gallery-section__header">
<h2 id="platform-routes-title"> </h2>
<p>
. , ,
.
</p>
</header>
<DataTable
caption="설치된 라우트 목록"
columns={ROUTE_COLUMNS}
rows={routes}
rowKey={(row) => row.routeId}
empty={
<EmptySurface
title="설치된 라우트가 없습니다."
description="라우트 레지스트리가 비어 있습니다."
/>
}
/>
</section>
<section
className="gallery-section"
aria-labelledby="platform-contracts-title"
>
<header className="gallery-section__header">
<h2 id="platform-contracts-title"> HTTP </h2>
<p>
{EXPECTED_CONTRACT_SET_PACKAGES.length}
. 릿
. .
</p>
</header>
<DataTable
caption="설치된 HTTP 오퍼레이션"
columns={OPERATION_COLUMNS}
rows={operations}
rowKey={(row) => row.operationId}
empty={
<EmptySurface
title="설치된 HTTP 오퍼레이션이 없습니다."
description="계약 기여물을 추가하면 이 표에 나타납니다."
/>
}
/>
</section>
<section
className="gallery-section"
aria-labelledby="platform-server-state-title"
>
<header className="gallery-section__header">
<h2 id="platform-server-state-title"> </h2>
<p>
,
.
</p>
</header>
<DataTable
caption="서버 상태 프로파일"
columns={PROFILE_COLUMNS}
rows={Object.values(SERVER_STATE_PROFILES)}
rowKey={(row) => row.profileId}
empty={<EmptySurface title="프로파일이 없습니다." />}
/>
<dl className="platform-metric-grid">
<Metric
label="응답 상한"
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardResponseBytes)}
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultResponseBytes)}`}
/>
<Metric
label="요청 상한"
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardRequestBytes)}
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultRequestBytes)}`}
/>
<Metric
label="총 마감 상한"
value={seconds(HTTP_EXECUTION_CEILINGS.hardTotalDeadlineMs)}
hint={`기본 ${seconds(HTTP_EXECUTION_CEILINGS.defaultTotalDeadlineMs)}`}
/>
<Metric
label="재시도 상한"
value={`${HTTP_EXECUTION_CEILINGS.hardRetryCount}`}
hint="전송 실패에만 적용"
/>
</dl>
</section>
<section
className="gallery-section"
aria-labelledby="platform-capabilities-title"
>
<header className="gallery-section__header">
<h2 id="platform-capabilities-title"> </h2>
<p>
.
,
.
, .
</p>
</header>
<div className="component-grid component-grid--two">
{capabilities.map((status) => {
const copy = CAPABILITY_COPY[status.capabilityId];
const badge = capabilityBadge(status);
return (
<Card
key={status.capabilityId}
title={copy.label}
footer={<Badge variant={badge.variant}>{badge.text}</Badge>}
>
<p>{copy.description}</p>
</Card>
);
})}
</div>
</section>
</section>
);
}
+4
View File
@@ -40,6 +40,8 @@ const PLATFORM_KO_MESSAGES = {
"shell.session.actionFailed": "세션 작업을 완료하지 못했습니다.",
"route.APP_HOME.navigation": "홈",
"route.APP_HOME.title": "Clean Architecture Frontend",
"route.EXAMPLES_PLATFORM.navigation": "플랫폼 구성",
"route.EXAMPLES_PLATFORM.title": "플랫폼 구성",
"route.EXAMPLES_UI.navigation": "UI 구성요소",
"route.EXAMPLES_UI.title": "UI 구성요소",
"route.EXAMPLES_STATES.navigation": "화면 상태",
@@ -190,6 +192,8 @@ const PLATFORM_EN_MESSAGES = {
"shell.session.actionFailed": "The session action could not be completed.",
"route.APP_HOME.navigation": "Home",
"route.APP_HOME.title": "Clean Architecture Frontend",
"route.EXAMPLES_PLATFORM.navigation": "Platform composition",
"route.EXAMPLES_PLATFORM.title": "Platform composition",
"route.EXAMPLES_UI.navigation": "UI components",
"route.EXAMPLES_UI.title": "UI components",
"route.EXAMPLES_STATES.navigation": "Screen states",
+11 -2
View File
@@ -59,10 +59,19 @@ export default function HomePage() {
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
<div>
<h2 id="starter-title"> </h2>
<p> .</p>
<p>
, ,
.
</p>
</div>
<div className="button-row">
<Link className="ui-button" to={routePath("EXAMPLES_UI")}>
<Link className="ui-button" to={routePath("EXAMPLES_PLATFORM")}>
</Link>
<Link
className="ui-button ui-button--secondary"
to={routePath("EXAMPLES_UI")}
>
UI
</Link>
<Link
+3 -1
View File
@@ -151,7 +151,9 @@ function CanonicalRouteRedirect({
params: input.params,
search: input.search,
});
if (source !== target && guard.current.allow(source, target)) {
if (source === target) {
guard.current.reset();
} else if (guard.current.allow(source, target)) {
void navigate(target, { replace: true });
}
}, [input, location.pathname, location.search, navigate]);
@@ -24,6 +24,10 @@ function runtime(
export const PLATFORM_ROUTE_RUNTIME = {
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.tsx")),
EXAMPLES_PLATFORM: runtime(
"EXAMPLES_PLATFORM",
() => import("../examples/platform-overview-page.tsx"),
),
EXAMPLES_UI: runtime(
"EXAMPLES_UI",
() => import("../examples/ui-gallery-page.tsx"),
+40
View File
@@ -1189,6 +1189,46 @@
gap: 1rem;
}
.platform-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1rem;
margin-block: 0;
}
.platform-metric {
display: grid;
gap: 0.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-surface);
background: var(--color-panel);
}
.platform-metric dt {
font-size: 0.8125rem;
color: var(--color-content-muted);
}
.platform-metric dd {
display: grid;
gap: 0.15rem;
margin-inline-start: 0;
}
.platform-metric__value {
font-weight: 600;
overflow-wrap: anywhere;
}
.platform-metric__hint,
.platform-operation__path {
display: block;
font-size: 0.8125rem;
color: var(--color-content-muted);
overflow-wrap: anywhere;
}
.badge-row {
display: flex;
flex-wrap: wrap;