refactor: 리펙토링
This commit is contained in:
@@ -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>);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }>;
|
||||
}>;
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* §5.3. The single canonical byte producer for a contract set.
|
||||
*
|
||||
* Node build scripts and the browser runtime share this function. Only the hash
|
||||
* adapter differs (Node `crypto` vs Web Crypto), so a digest can never diverge
|
||||
* because of JSON property order or a locale-sensitive sort.
|
||||
*/
|
||||
|
||||
export type ContractSetPackage = Readonly<{
|
||||
packageId: string;
|
||||
version: string;
|
||||
digest: `sha256:${string}`;
|
||||
runtimeProtocolVersion: 1;
|
||||
sourceRevision: string;
|
||||
}>;
|
||||
|
||||
export const CONTRACT_SET_ALGORITHM = "CA_CONTRACT_SET_V1" as const;
|
||||
|
||||
const HEADER = "CA_FRONTEND_CONTRACT_SET_V1\u0000";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function compareUtf8(left: string, right: string): number {
|
||||
const a = encoder.encode(left);
|
||||
const b = encoder.encode(right);
|
||||
const shared = Math.min(a.length, b.length);
|
||||
for (let index = 0; index < shared; index += 1) {
|
||||
const difference = (a[index] as number) - (b[index] as number);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return a.length - b.length;
|
||||
}
|
||||
|
||||
export function canonicalizeContractSet(
|
||||
packages: readonly ContractSetPackage[],
|
||||
): Uint8Array {
|
||||
const seen = new Set<string>();
|
||||
for (const entry of packages) {
|
||||
if (seen.has(entry.packageId)) {
|
||||
throw new TypeError("Duplicate contract set package identity.");
|
||||
}
|
||||
seen.add(entry.packageId);
|
||||
}
|
||||
|
||||
const sorted = [...packages].sort((left, right) =>
|
||||
compareUtf8(left.packageId, right.packageId),
|
||||
);
|
||||
|
||||
const chunks: Uint8Array[] = [encoder.encode(HEADER)];
|
||||
for (const entry of sorted) {
|
||||
appendString(chunks, entry.packageId);
|
||||
appendString(chunks, entry.version);
|
||||
appendString(chunks, entry.digest);
|
||||
chunks.push(u32be(entry.runtimeProtocolVersion));
|
||||
appendString(chunks, entry.sourceRevision);
|
||||
}
|
||||
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function appendString(chunks: Uint8Array[], value: string): void {
|
||||
const bytes = encoder.encode(value);
|
||||
chunks.push(u32be(bytes.length));
|
||||
chunks.push(bytes);
|
||||
}
|
||||
|
||||
function u32be(value: number): Uint8Array {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) {
|
||||
throw new TypeError("Contract set length prefix is out of range.");
|
||||
}
|
||||
const bytes = new Uint8Array(4);
|
||||
new DataView(bytes.buffer).setUint32(0, value, false);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function toLowerHex(digest: ArrayBuffer | Uint8Array): string {
|
||||
const bytes =
|
||||
digest instanceof Uint8Array ? digest : new Uint8Array(digest);
|
||||
let output = "";
|
||||
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-side digest. Node callers pass their own `crypto.createHash` adapter
|
||||
* through {@link computeContractSetDigestWith}.
|
||||
*/
|
||||
export async function computeContractSetDigest(
|
||||
packages: readonly ContractSetPackage[],
|
||||
): Promise<`sha256:${string}`> {
|
||||
const bytes = canonicalizeContractSet(packages);
|
||||
const buffer = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
bytes.slice().buffer as ArrayBuffer,
|
||||
);
|
||||
return `sha256:${toLowerHex(buffer)}`;
|
||||
}
|
||||
|
||||
export function computeContractSetDigestWith(
|
||||
packages: readonly ContractSetPackage[],
|
||||
sha256: (bytes: Uint8Array) => Uint8Array,
|
||||
): `sha256:${string}` {
|
||||
return `sha256:${toLowerHex(sha256(canonicalizeContractSet(packages)))}`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CONTRACT_SET_ALGORITHM,
|
||||
computeContractSetDigest,
|
||||
type ContractSetPackage,
|
||||
} from "./contract-set-canonical.ts";
|
||||
|
||||
/**
|
||||
* §5. Contract set and release coherence.
|
||||
*
|
||||
* The frontend verifies that the packages compiled into this build match the
|
||||
* packages the release manifest declares. It never negotiates ranges, resolves
|
||||
* `latest`, or infers a provider runtime version.
|
||||
*/
|
||||
|
||||
export type ContractSetFailureCode =
|
||||
| "CONTRACT_SET_SCHEMA_INVALID"
|
||||
| "CONTRACT_SET_ENTRY_INVALID"
|
||||
| "CONTRACT_SET_DUPLICATE_PACKAGE"
|
||||
| "CONTRACT_SET_DIGEST_INVALID"
|
||||
| "CONTRACT_SET_DIGEST_MISMATCH"
|
||||
| "CONTRACT_SET_PACKAGE_MISSING"
|
||||
| "CONTRACT_SET_PACKAGE_UNEXPECTED"
|
||||
| "CONTRACT_SET_VERSION_MISMATCH"
|
||||
| "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED";
|
||||
|
||||
const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
||||
|
||||
export const contractSetPackageSchema = z
|
||||
.object({
|
||||
packageId: z
|
||||
.string()
|
||||
.regex(/^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/),
|
||||
version: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/,
|
||||
),
|
||||
digest: digestSchema,
|
||||
runtimeProtocolVersion: z.literal(1),
|
||||
sourceRevision: z.string().regex(/^[0-9a-f]{7,64}$/),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const contractSetSchema = z
|
||||
.object({
|
||||
setAlgorithm: z.literal(CONTRACT_SET_ALGORITHM),
|
||||
setDigest: digestSchema,
|
||||
packages: z.array(contractSetPackageSchema).max(256),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ContractSet = z.output<typeof contractSetSchema>;
|
||||
|
||||
export type ContractSetVerification =
|
||||
| Readonly<{ ok: true }>
|
||||
| Readonly<{ ok: false; code: ContractSetFailureCode }>;
|
||||
|
||||
/**
|
||||
* §5.5. Every comparison below must hold. There is no precedence rule between
|
||||
* the embedded expectation and the manifest: a disagreement fails the boot.
|
||||
*/
|
||||
export async function verifyContractSet(
|
||||
input: Readonly<{
|
||||
expected: readonly ContractSetPackage[];
|
||||
manifest: ContractSet;
|
||||
expectedSetDigest?: `sha256:${string}`;
|
||||
}>,
|
||||
): Promise<ContractSetVerification> {
|
||||
const manifestIds = new Set<string>();
|
||||
for (const entry of input.manifest.packages) {
|
||||
if (manifestIds.has(entry.packageId)) {
|
||||
return failure("CONTRACT_SET_DUPLICATE_PACKAGE");
|
||||
}
|
||||
manifestIds.add(entry.packageId);
|
||||
if (entry.runtimeProtocolVersion !== 1) {
|
||||
return failure("CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
const expectedById = new Map(
|
||||
input.expected.map((entry) => [entry.packageId, entry] as const),
|
||||
);
|
||||
for (const entry of input.manifest.packages) {
|
||||
if (!expectedById.has(entry.packageId)) {
|
||||
return failure("CONTRACT_SET_PACKAGE_UNEXPECTED");
|
||||
}
|
||||
}
|
||||
for (const entry of input.expected) {
|
||||
const found = input.manifest.packages.find(
|
||||
(candidate) => candidate.packageId === entry.packageId,
|
||||
);
|
||||
if (!found) return failure("CONTRACT_SET_PACKAGE_MISSING");
|
||||
if (
|
||||
found.version !== entry.version ||
|
||||
found.sourceRevision !== entry.sourceRevision
|
||||
) {
|
||||
return failure("CONTRACT_SET_VERSION_MISMATCH");
|
||||
}
|
||||
if (found.digest !== entry.digest) {
|
||||
return failure("CONTRACT_SET_DIGEST_MISMATCH");
|
||||
}
|
||||
}
|
||||
|
||||
let recomputed: `sha256:${string}`;
|
||||
try {
|
||||
recomputed = await computeContractSetDigest(
|
||||
input.manifest.packages as readonly ContractSetPackage[],
|
||||
);
|
||||
} catch {
|
||||
return failure("CONTRACT_SET_DIGEST_INVALID");
|
||||
}
|
||||
if (recomputed !== input.manifest.setDigest) {
|
||||
return failure("CONTRACT_SET_DIGEST_MISMATCH");
|
||||
}
|
||||
|
||||
const expectedDigest =
|
||||
input.expectedSetDigest ?? (await computeContractSetDigest(input.expected));
|
||||
if (expectedDigest !== input.manifest.setDigest) {
|
||||
return failure("CONTRACT_SET_DIGEST_MISMATCH");
|
||||
}
|
||||
|
||||
return Object.freeze({ ok: true as const });
|
||||
}
|
||||
|
||||
function failure(code: ContractSetFailureCode): ContractSetVerification {
|
||||
return Object.freeze({ ok: false as const, code });
|
||||
}
|
||||
+43
-4
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* External contract package consumer boundary (§4).
|
||||
*
|
||||
* This repository does not own OpenAPI/AsyncAPI source, operation semantics,
|
||||
* Problem Details meaning or event payload schemas. It owns only the normalized
|
||||
* descriptor interface, the runtime validator protocol, and the bounds it
|
||||
* applies before a contribution may be composed.
|
||||
*/
|
||||
|
||||
/** §7.3 hard ceilings. A contribution may lower these, never raise them. */
|
||||
export const HTTP_EXECUTION_CEILINGS = Object.freeze({
|
||||
defaultRequestBytes: 262_144,
|
||||
hardRequestBytes: 1_048_576,
|
||||
defaultResponseBytes: 1_048_576,
|
||||
hardResponseBytes: 8_388_608,
|
||||
problemResponseBytes: 65_536,
|
||||
pathTemplateBytes: 512,
|
||||
encodedQueryBytes: 8_192,
|
||||
examinedHeaderValueBytes: 8_192,
|
||||
defaultTotalDeadlineMs: 10_000,
|
||||
hardTotalDeadlineMs: 60_000,
|
||||
hardRetryCount: 2,
|
||||
finalUrlBytes: 16_384,
|
||||
});
|
||||
|
||||
export type RuntimeValidationIssue = Readonly<{
|
||||
path: readonly (string | number)[];
|
||||
code: string;
|
||||
}>;
|
||||
|
||||
export type RuntimeValidationResult<T> =
|
||||
| Readonly<{ success: true; data: T }>
|
||||
| Readonly<{ success: false; issues: readonly RuntimeValidationIssue[] }>;
|
||||
|
||||
export interface RuntimeValidator<T> {
|
||||
readonly schemaId: string;
|
||||
safeParse(value: unknown): RuntimeValidationResult<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A validator invocation never escapes as a native throw. `THROWN` is the
|
||||
* `CONTRACT_RUNTIME_FAILURE` signal; `false` success is the ordinary
|
||||
* `CONTRACT_VALUE_INVALID` signal.
|
||||
*/
|
||||
export type ValidatorInvocation<T> =
|
||||
| Readonly<{ outcome: "VALID"; value: T }>
|
||||
| Readonly<{ outcome: "INVALID"; issues: readonly RuntimeValidationIssue[] }>
|
||||
| Readonly<{ outcome: "THROWN" }>;
|
||||
|
||||
export function invokeValidator<T>(
|
||||
validator: RuntimeValidator<T>,
|
||||
value: unknown,
|
||||
): ValidatorInvocation<T> {
|
||||
let result: RuntimeValidationResult<T>;
|
||||
try {
|
||||
result = validator.safeParse(value);
|
||||
} catch {
|
||||
return Object.freeze({ outcome: "THROWN" as const });
|
||||
}
|
||||
if (!result || typeof result !== "object" || !("success" in result)) {
|
||||
return Object.freeze({ outcome: "THROWN" as const });
|
||||
}
|
||||
if (result.success) {
|
||||
return Object.freeze({ outcome: "VALID" as const, value: result.data });
|
||||
}
|
||||
return Object.freeze({
|
||||
outcome: "INVALID" as const,
|
||||
issues: Object.freeze([...(result.issues ?? [])]),
|
||||
});
|
||||
}
|
||||
|
||||
export type MappingResult<T> =
|
||||
| Readonly<{ ok: true; value: T }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
error: Readonly<{ kind: "MAPPING_CONTRACT_VIOLATION"; code: string }>;
|
||||
}>;
|
||||
|
||||
export function mappingViolation(code: string): MappingResult<never> {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
error: Object.freeze({
|
||||
kind: "MAPPING_CONTRACT_VIOLATION" as const,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export type CommandRecoveryDescriptor = Readonly<{
|
||||
mode: "IDEMPOTENCY_REPLAY" | "INSPECT_OPERATION";
|
||||
operationIdentityField: string;
|
||||
inspectOperationId?: string;
|
||||
}>;
|
||||
|
||||
export type CommandEffectClassification =
|
||||
| "NOT_APPLIED"
|
||||
| "APPLIED_CONFIRMED"
|
||||
| "MAYBE_APPLIED";
|
||||
|
||||
export interface CommandEffectDescriptor<Problem> {
|
||||
readonly successEffect: "APPLIED_CONFIRMED";
|
||||
classifyProblem(
|
||||
input: Readonly<{ status: number; problem: Problem }>,
|
||||
): CommandEffectClassification;
|
||||
}
|
||||
|
||||
export type HttpMethod =
|
||||
| "GET"
|
||||
| "HEAD"
|
||||
| "POST"
|
||||
| "PUT"
|
||||
| "PATCH"
|
||||
| "DELETE";
|
||||
|
||||
export type RetrySemantics = "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER";
|
||||
|
||||
export interface HttpExecutionPolicy {
|
||||
readonly policyId: string;
|
||||
readonly requestByteLimit: number;
|
||||
readonly responseByteLimit: number;
|
||||
readonly totalDeadlineMs: number;
|
||||
readonly retryBudget: 0 | 1 | 2;
|
||||
readonly authProfileId: string;
|
||||
readonly diagnosticsOperation: string;
|
||||
}
|
||||
|
||||
export interface InstalledHttpContract<Input, WireOutput, Problem> {
|
||||
readonly contract: Readonly<{
|
||||
operationId: string;
|
||||
method: HttpMethod;
|
||||
pathTemplate: string;
|
||||
inputValidator: RuntimeValidator<Input>;
|
||||
outputValidator: RuntimeValidator<WireOutput>;
|
||||
problemValidator: RuntimeValidator<Problem>;
|
||||
acceptedStatuses: readonly number[];
|
||||
emptyBodyStatuses: readonly number[];
|
||||
retrySemantics: RetrySemantics;
|
||||
requestBody: "NONE" | "JSON";
|
||||
responseBody: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE";
|
||||
commandRecovery: CommandRecoveryDescriptor | null;
|
||||
commandEffect: CommandEffectDescriptor<Problem> | null;
|
||||
/**
|
||||
* Descriptor-owned projection from canonical application input to the wire
|
||||
* request. The frontend never re-derives method, path or query semantics.
|
||||
*/
|
||||
projectRequest(input: Input): HttpRequestProjection;
|
||||
}>;
|
||||
readonly frontend: HttpExecutionPolicy;
|
||||
}
|
||||
|
||||
export type HttpRequestProjection = Readonly<{
|
||||
/** Ordered path placeholder values keyed by descriptor placeholder name. */
|
||||
pathValues: Readonly<Record<string, string>>;
|
||||
/** Descriptor-generated query entry order; array encoding is descriptor-owned. */
|
||||
queryEntries: readonly (readonly [string, string])[];
|
||||
/** Canonical JSON body value, or `null` when `requestBody` is `NONE`. */
|
||||
body: unknown;
|
||||
}>;
|
||||
|
||||
export interface InstalledEventContract<Envelope, Payload> {
|
||||
readonly eventType: string;
|
||||
readonly envelopeValidator: RuntimeValidator<Envelope>;
|
||||
readonly payloadValidator: RuntimeValidator<Payload>;
|
||||
}
|
||||
|
||||
export interface InstalledContractPackageIdentity {
|
||||
readonly packageId: string;
|
||||
readonly version: string;
|
||||
readonly digest: `sha256:${string}`;
|
||||
readonly runtimeProtocolVersion: 1;
|
||||
readonly sourceRevision: string;
|
||||
}
|
||||
|
||||
export type ContractContributionSource =
|
||||
| Readonly<{
|
||||
kind: "EXTERNAL_PACKAGE";
|
||||
package: InstalledContractPackageIdentity;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "TEMPLATE_FIXTURE";
|
||||
fixtureId: "REFERENCE_FEATURE_V1";
|
||||
revision: 1;
|
||||
}>;
|
||||
|
||||
export interface InstalledContractContribution {
|
||||
readonly contributionId: string;
|
||||
readonly featureId: string;
|
||||
readonly source: ContractContributionSource;
|
||||
readonly http: readonly InstalledHttpContract<unknown, unknown, unknown>[];
|
||||
readonly events: readonly InstalledEventContract<unknown, unknown>[];
|
||||
}
|
||||
|
||||
export type ContractCompositionFailureCode =
|
||||
| "CONTRACT_CONTRIBUTION_INVALID"
|
||||
| "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED";
|
||||
|
||||
export class ContractContributionError extends Error {
|
||||
readonly code: ContractCompositionFailureCode;
|
||||
readonly reason: string;
|
||||
|
||||
constructor(reason: string, code: ContractCompositionFailureCode = "CONTRACT_CONTRIBUTION_INVALID") {
|
||||
super("Installed contract contribution is not composable");
|
||||
this.name = "ContractContributionError";
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// §4.9 exact validation vocabulary.
|
||||
const FEATURE_ID = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9._-]{0,127}$/;
|
||||
const PACKAGE_ID =
|
||||
/^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/;
|
||||
const SEM_VER =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
||||
const SOURCE_REVISION = /^[0-9a-f]{7,64}$/;
|
||||
const OPERATION_ID = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
|
||||
/** Whitespace and C0/C1 control characters are rejected in an event type. */
|
||||
function hasControlOrSpace(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code <= 0x20 || (code >= 0x7f && code <= 0x9f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return encoder.encode(value).byteLength;
|
||||
}
|
||||
|
||||
function fail(reason: string): never {
|
||||
throw new ContractContributionError(reason);
|
||||
}
|
||||
|
||||
function assertPackageIdentity(
|
||||
identity: InstalledContractPackageIdentity,
|
||||
featureId: string,
|
||||
): void {
|
||||
if (!identity || typeof identity !== "object") {
|
||||
fail(`${featureId}: package identity`);
|
||||
}
|
||||
if (identity.runtimeProtocolVersion !== 1) {
|
||||
throw new ContractContributionError(
|
||||
`${featureId}: runtime protocol version must be exactly 1`,
|
||||
"CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED",
|
||||
);
|
||||
}
|
||||
if (!PACKAGE_ID.test(identity.packageId)) fail(`${featureId}: packageId`);
|
||||
if (
|
||||
typeof identity.version !== "string" ||
|
||||
identity.version !== identity.version.trim() ||
|
||||
identity.version.startsWith("v") ||
|
||||
!SEM_VER.test(identity.version)
|
||||
) {
|
||||
fail(`${featureId}: package version must be exact SemVer`);
|
||||
}
|
||||
if (!DIGEST.test(identity.digest)) fail(`${featureId}: package digest`);
|
||||
if (!SOURCE_REVISION.test(identity.sourceRevision)) {
|
||||
fail(`${featureId}: package sourceRevision`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidator(
|
||||
validator: RuntimeValidator<unknown>,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!validator ||
|
||||
typeof validator.safeParse !== "function" ||
|
||||
typeof validator.schemaId !== "string" ||
|
||||
validator.schemaId.length === 0
|
||||
) {
|
||||
fail(`${label}: runtime validator with non-empty schemaId is required`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStatuses(
|
||||
statuses: readonly number[],
|
||||
label: string,
|
||||
): void {
|
||||
if (!Array.isArray(statuses)) fail(`${label}: status array required`);
|
||||
if (statuses.length < 1 || statuses.length > 32) {
|
||||
fail(`${label}: 1..32 statuses required`);
|
||||
}
|
||||
for (let index = 0; index < statuses.length; index += 1) {
|
||||
const status = statuses[index] as number;
|
||||
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
||||
fail(`${label}: status out of range`);
|
||||
}
|
||||
const previous = statuses[index - 1];
|
||||
if (index > 0 && previous !== undefined && status <= previous) {
|
||||
fail(`${label}: statuses must be sorted and unique`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExecutionPolicy(
|
||||
policy: HttpExecutionPolicy,
|
||||
label: string,
|
||||
): void {
|
||||
const ceilings = HTTP_EXECUTION_CEILINGS;
|
||||
if (
|
||||
!policy ||
|
||||
typeof policy.policyId !== "string" ||
|
||||
policy.policyId.length === 0 ||
|
||||
typeof policy.authProfileId !== "string" ||
|
||||
policy.authProfileId.length === 0 ||
|
||||
typeof policy.diagnosticsOperation !== "string" ||
|
||||
policy.diagnosticsOperation.length === 0
|
||||
) {
|
||||
fail(`${label}: frontend execution policy identity`);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.requestByteLimit) ||
|
||||
policy.requestByteLimit < 0 ||
|
||||
policy.requestByteLimit > ceilings.hardRequestBytes
|
||||
) {
|
||||
fail(`${label}: requestByteLimit exceeds the hard ceiling`);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.responseByteLimit) ||
|
||||
policy.responseByteLimit < 1 ||
|
||||
policy.responseByteLimit > ceilings.hardResponseBytes
|
||||
) {
|
||||
fail(`${label}: responseByteLimit exceeds the hard ceiling`);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.totalDeadlineMs) ||
|
||||
policy.totalDeadlineMs < 1 ||
|
||||
policy.totalDeadlineMs > ceilings.hardTotalDeadlineMs
|
||||
) {
|
||||
fail(`${label}: totalDeadlineMs exceeds the hard ceiling`);
|
||||
}
|
||||
if (
|
||||
policy.retryBudget !== 0 &&
|
||||
policy.retryBudget !== 1 &&
|
||||
policy.retryBudget !== 2
|
||||
) {
|
||||
fail(`${label}: retryBudget must be 0, 1 or 2`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertHttpContract(
|
||||
installed: InstalledHttpContract<unknown, unknown, unknown>,
|
||||
featureId: string,
|
||||
): void {
|
||||
const contract = installed?.contract;
|
||||
if (!contract || typeof contract !== "object") {
|
||||
fail(`${featureId}: http contract descriptor missing`);
|
||||
}
|
||||
const label = `${featureId}/${String(contract.operationId)}`;
|
||||
|
||||
if (
|
||||
typeof contract.operationId !== "string" ||
|
||||
!OPERATION_ID.test(contract.operationId)
|
||||
) {
|
||||
fail(`${label}: operationId`);
|
||||
}
|
||||
if (
|
||||
contract.method !== "GET" &&
|
||||
contract.method !== "HEAD" &&
|
||||
contract.method !== "POST" &&
|
||||
contract.method !== "PUT" &&
|
||||
contract.method !== "PATCH" &&
|
||||
contract.method !== "DELETE"
|
||||
) {
|
||||
fail(`${label}: method`);
|
||||
}
|
||||
if (
|
||||
typeof contract.pathTemplate !== "string" ||
|
||||
!contract.pathTemplate.startsWith("/") ||
|
||||
utf8Bytes(contract.pathTemplate) > HTTP_EXECUTION_CEILINGS.pathTemplateBytes ||
|
||||
contract.pathTemplate.includes("?") ||
|
||||
contract.pathTemplate.includes("#")
|
||||
) {
|
||||
fail(`${label}: pathTemplate`);
|
||||
}
|
||||
assertValidator(contract.inputValidator, `${label}.input`);
|
||||
assertValidator(contract.outputValidator, `${label}.output`);
|
||||
assertValidator(contract.problemValidator, `${label}.problem`);
|
||||
if (typeof contract.projectRequest !== "function") {
|
||||
fail(`${label}: descriptor request projection is required`);
|
||||
}
|
||||
if (
|
||||
contract.retrySemantics !== "SAFE" &&
|
||||
contract.retrySemantics !== "IDEMPOTENT" &&
|
||||
contract.retrySemantics !== "KEYED" &&
|
||||
contract.retrySemantics !== "NEVER"
|
||||
) {
|
||||
fail(`${label}: retrySemantics`);
|
||||
}
|
||||
if (contract.requestBody !== "NONE" && contract.requestBody !== "JSON") {
|
||||
fail(`${label}: requestBody`);
|
||||
}
|
||||
if (
|
||||
contract.responseBody !== "REQUIRED_JSON" &&
|
||||
contract.responseBody !== "OPTIONAL_JSON" &&
|
||||
contract.responseBody !== "NONE"
|
||||
) {
|
||||
fail(`${label}: responseBody`);
|
||||
}
|
||||
assertStatuses(contract.acceptedStatuses, `${label}.acceptedStatuses`);
|
||||
if (contract.emptyBodyStatuses.length > 0) {
|
||||
assertStatuses(contract.emptyBodyStatuses, `${label}.emptyBodyStatuses`);
|
||||
const accepted = new Set(contract.acceptedStatuses);
|
||||
for (const status of contract.emptyBodyStatuses) {
|
||||
if (!accepted.has(status)) {
|
||||
fail(`${label}: empty-body status must be an accepted status`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const isRead = contract.method === "GET" || contract.method === "HEAD";
|
||||
if (isRead) {
|
||||
if (contract.commandEffect !== null || contract.commandRecovery !== null) {
|
||||
fail(`${label}: read operations carry no command descriptors`);
|
||||
}
|
||||
if (contract.requestBody !== "NONE") {
|
||||
fail(`${label}: read operations carry no request body`);
|
||||
}
|
||||
} else {
|
||||
if (!contract.commandEffect) {
|
||||
fail(`${label}: command operations require a command effect descriptor`);
|
||||
}
|
||||
if (contract.commandEffect.successEffect !== "APPLIED_CONFIRMED") {
|
||||
fail(`${label}: command success effect`);
|
||||
}
|
||||
if (typeof contract.commandEffect.classifyProblem !== "function") {
|
||||
fail(`${label}: command effect classifier is required`);
|
||||
}
|
||||
if (contract.retrySemantics === "KEYED" && !contract.commandRecovery) {
|
||||
fail(`${label}: KEYED commands require a recovery descriptor`);
|
||||
}
|
||||
}
|
||||
if (contract.commandRecovery) {
|
||||
const recovery = contract.commandRecovery;
|
||||
if (
|
||||
(recovery.mode !== "IDEMPOTENCY_REPLAY" &&
|
||||
recovery.mode !== "INSPECT_OPERATION") ||
|
||||
!recovery.operationIdentityField ||
|
||||
(recovery.mode === "INSPECT_OPERATION" && !recovery.inspectOperationId)
|
||||
) {
|
||||
fail(`${label}: command recovery descriptor`);
|
||||
}
|
||||
}
|
||||
assertExecutionPolicy(installed.frontend, label);
|
||||
if (
|
||||
installed.frontend.retryBudget > 0 &&
|
||||
contract.retrySemantics === "NEVER"
|
||||
) {
|
||||
fail(`${label}: retry budget contradicts NEVER retry semantics`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEventContract(
|
||||
event: InstalledEventContract<unknown, unknown>,
|
||||
featureId: string,
|
||||
): void {
|
||||
const label = `${featureId}/${String(event?.eventType)}`;
|
||||
if (
|
||||
typeof event?.eventType !== "string" ||
|
||||
event.eventType.length === 0 ||
|
||||
utf8Bytes(event.eventType) > 160 ||
|
||||
hasControlOrSpace(event.eventType)
|
||||
) {
|
||||
fail(`${label}: eventType`);
|
||||
}
|
||||
assertValidator(event.envelopeValidator, `${label}.envelope`);
|
||||
assertValidator(event.payloadValidator, `${label}.payload`);
|
||||
}
|
||||
|
||||
export type ComposedContractContributions = Readonly<{
|
||||
contributions: readonly InstalledContractContribution[];
|
||||
httpByOperationId: ReadonlyMap<
|
||||
string,
|
||||
InstalledHttpContract<unknown, unknown, unknown>
|
||||
>;
|
||||
eventByType: ReadonlyMap<string, InstalledEventContract<unknown, unknown>>;
|
||||
externalPackages: readonly InstalledContractPackageIdentity[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §4.8–§4.9. The only place installed contributions become a runtime registry.
|
||||
* Every bound is checked before composition; a violation stops the boot rather
|
||||
* than degrading into an assumed meaning.
|
||||
*/
|
||||
export function composeContractContributions(
|
||||
contributions: readonly InstalledContractContribution[],
|
||||
): ComposedContractContributions {
|
||||
if (!Array.isArray(contributions)) fail("contributions: array required");
|
||||
const httpByOperationId = new Map<
|
||||
string,
|
||||
InstalledHttpContract<unknown, unknown, unknown>
|
||||
>();
|
||||
const eventByType = new Map<
|
||||
string,
|
||||
InstalledEventContract<unknown, unknown>
|
||||
>();
|
||||
const packagesById = new Map<string, InstalledContractPackageIdentity>();
|
||||
const contributionIds = new Set<string>();
|
||||
|
||||
for (const contribution of contributions) {
|
||||
if (!contribution || typeof contribution !== "object") {
|
||||
fail("contribution: object required");
|
||||
}
|
||||
const contributionId = contribution.contributionId;
|
||||
if (
|
||||
typeof contributionId !== "string" ||
|
||||
!CONTRIBUTION_ID.test(contributionId)
|
||||
) {
|
||||
fail(`contributionId: ${String(contributionId)}`);
|
||||
}
|
||||
if (contributionIds.has(contributionId)) {
|
||||
fail(`duplicate contributionId: ${contributionId}`);
|
||||
}
|
||||
contributionIds.add(contributionId);
|
||||
const featureId = contribution?.featureId;
|
||||
if (typeof featureId !== "string" || !FEATURE_ID.test(featureId)) {
|
||||
fail(`featureId: ${String(featureId)}`);
|
||||
}
|
||||
const source = contribution.source;
|
||||
if (!source || typeof source !== "object" || !("kind" in source)) {
|
||||
fail(`${featureId}: source`);
|
||||
}
|
||||
if (!Array.isArray(contribution.http) || !Array.isArray(contribution.events)) {
|
||||
fail(`${featureId}: contribution arrays`);
|
||||
}
|
||||
if (source.kind === "EXTERNAL_PACKAGE") {
|
||||
assertPackageIdentity(source.package, featureId);
|
||||
const existing = packagesById.get(source.package.packageId);
|
||||
if (
|
||||
existing &&
|
||||
(existing.version !== source.package.version ||
|
||||
existing.digest !== source.package.digest ||
|
||||
existing.sourceRevision !== source.package.sourceRevision)
|
||||
) {
|
||||
fail(
|
||||
`${featureId}: package ${source.package.packageId} has conflicting identities`,
|
||||
);
|
||||
}
|
||||
packagesById.set(source.package.packageId, source.package);
|
||||
} else if (source.kind === "TEMPLATE_FIXTURE") {
|
||||
if (source.fixtureId !== "REFERENCE_FEATURE_V1" || source.revision !== 1) {
|
||||
fail(`${featureId}: template fixture identity`);
|
||||
}
|
||||
if (contribution.events.length !== 0) {
|
||||
fail(`${featureId}: template fixture must not contribute events`);
|
||||
}
|
||||
} else {
|
||||
fail(`${featureId}: unknown contribution source kind`);
|
||||
}
|
||||
|
||||
for (const installed of contribution.http) {
|
||||
assertHttpContract(installed, featureId);
|
||||
const operationId = installed.contract.operationId;
|
||||
const previous = httpByOperationId.get(operationId);
|
||||
if (previous) fail(`duplicate operation: ${operationId}`);
|
||||
httpByOperationId.set(operationId, installed);
|
||||
}
|
||||
|
||||
for (const event of contribution.events) {
|
||||
assertEventContract(event, featureId);
|
||||
if (eventByType.has(event.eventType)) {
|
||||
fail(`duplicate event type: ${event.eventType}`);
|
||||
}
|
||||
eventByType.set(event.eventType, event);
|
||||
}
|
||||
}
|
||||
|
||||
const externalPackages = [...packagesById.values()].map((identity) =>
|
||||
Object.freeze({ ...identity }),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
contributions: Object.freeze([...contributions]),
|
||||
httpByOperationId,
|
||||
eventByType,
|
||||
externalPackages: Object.freeze(externalPackages),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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]),
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* §17–§18. Service Worker contract.
|
||||
*
|
||||
* One physical registration per scope covers PWA lifecycle, verified static
|
||||
* asset fetch, Web Push and the optional sync wake-up. A separate registration
|
||||
* for any of those is prohibited.
|
||||
*/
|
||||
|
||||
export const SERVICE_WORKER_PROTOCOL_VERSION = 1 as const;
|
||||
export const SERVICE_WORKER_CACHE_SCHEMA_VERSION = 1 as const;
|
||||
export const SERVICE_WORKER_SCRIPT_PATH = "service-worker.js" as const;
|
||||
|
||||
export const SERVICE_WORKER_BOUNDS = Object.freeze({
|
||||
assets: 256,
|
||||
singleAssetBytes: 2 * 1024 * 1024,
|
||||
assetSetBytes: 5 * 1024 * 1024,
|
||||
fetchConcurrency: 4,
|
||||
installDeadlineMs: 60_000,
|
||||
clientDrainMs: 30_000,
|
||||
updateCheckIntervalMs: 6 * 60 * 60 * 1_000,
|
||||
retainedPreviousCaches: 1,
|
||||
reloadGuardBytes: 512,
|
||||
reloadGuardTtlMs: 10 * 60 * 1_000,
|
||||
});
|
||||
|
||||
export type ServiceWorkerHandlerId =
|
||||
| "WEB_PUSH"
|
||||
| "PWA_STATIC_ASSETS"
|
||||
| "OFFLINE_SYNC_WAKEUP";
|
||||
|
||||
/**
|
||||
* §17.3. Removal is staged. `ACTIVE` never transitions directly to `null`:
|
||||
* a registration that already exists in a browser must first be unregistered,
|
||||
* then have its owned resources purged, before the source may disappear.
|
||||
*/
|
||||
export type InstalledServiceWorkerSelection =
|
||||
| Readonly<{
|
||||
mode: "ACTIVE";
|
||||
scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH;
|
||||
handlers: readonly ServiceWorkerHandlerId[];
|
||||
}>
|
||||
| Readonly<{
|
||||
mode: "REMOVE_REGISTRATION";
|
||||
scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH;
|
||||
}>
|
||||
| Readonly<{
|
||||
mode: "PURGE_OWNED_RESOURCES";
|
||||
scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH;
|
||||
}>;
|
||||
|
||||
/** §17.7. Page and worker read the same compile-time identity tuple. */
|
||||
export type ServiceWorkerProtocolIdentity = Readonly<{
|
||||
serviceWorkerProtocolVersion: typeof SERVICE_WORKER_PROTOCOL_VERSION;
|
||||
cacheSchemaVersion: typeof SERVICE_WORKER_CACHE_SCHEMA_VERSION;
|
||||
buildId: string;
|
||||
releaseId: string;
|
||||
contractSetDigest: string;
|
||||
staticAssetSetDigest: string;
|
||||
}>;
|
||||
|
||||
export type ServiceWorkerMessageKind =
|
||||
| "PAGE_HELLO"
|
||||
| "WORKER_HELLO_ACK"
|
||||
| "UPDATE_READY"
|
||||
| "ACTIVATE_REQUEST"
|
||||
| "ACTIVATE_ACCEPTED"
|
||||
| "ACTIVATE_REJECTED"
|
||||
| "CLIENT_DRAIN_REQUEST"
|
||||
| "CLIENT_DRAINED"
|
||||
| "ACTIVATED_RELOAD_REQUIRED"
|
||||
| "CACHE_RESET_REQUEST"
|
||||
| "CACHE_RESET_RESULT"
|
||||
| "SYNC_WAKE_OBSERVED";
|
||||
|
||||
export type ServiceWorkerMessage = Readonly<{
|
||||
protocolVersion: typeof SERVICE_WORKER_PROTOCOL_VERSION;
|
||||
kind: ServiceWorkerMessageKind;
|
||||
messageId: string;
|
||||
sourceBuildId: string;
|
||||
targetBuildId?: string;
|
||||
nonce?: string;
|
||||
/** Present only on a successful CACHE_RESET_RESULT. */
|
||||
cachesDeleted?: number;
|
||||
}>;
|
||||
|
||||
/** §18.3. Compile-time asset manifest; the worker never fetches one. */
|
||||
export interface StaticAssetManifestV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly buildId: string;
|
||||
readonly releaseId: string;
|
||||
readonly setDigest: `sha256:${string}`;
|
||||
readonly assets: readonly Readonly<{
|
||||
url: string;
|
||||
sha256: `sha256:${string}`;
|
||||
bytes: number;
|
||||
contentType: string;
|
||||
}>[];
|
||||
}
|
||||
|
||||
/** §18.2. `ca-static-v1-<first 16 lower-hex of staticAssetSetDigest>`. */
|
||||
export const STATIC_CACHE_PREFIX = "ca-static-v1-" as const;
|
||||
|
||||
export function staticCacheName(setDigest: string): string {
|
||||
const hex = setDigest.replace(/^sha256:/, "").slice(0, 16);
|
||||
if (!/^[0-9a-f]{16}$/.test(hex)) {
|
||||
throw new TypeError("Static asset set digest is invalid.");
|
||||
}
|
||||
return `${STATIC_CACHE_PREFIX}${hex}`;
|
||||
}
|
||||
|
||||
export function isOwnedStaticCacheName(name: string): boolean {
|
||||
return (
|
||||
name.startsWith(STATIC_CACHE_PREFIX) &&
|
||||
/^[0-9a-f]{16}$/.test(name.slice(STATIC_CACHE_PREFIX.length))
|
||||
);
|
||||
}
|
||||
|
||||
export type ServiceWorkerStartOutcome =
|
||||
| Readonly<{ kind: "ACTIVE"; buildId: string }>
|
||||
| Readonly<{ kind: "RELOAD_TO_ENABLE" }>
|
||||
| Readonly<{ kind: "UPDATE_WAITING" }>
|
||||
| Readonly<{ kind: "DISABLED" }>
|
||||
| Readonly<{ kind: "INCOMPATIBLE" }>
|
||||
| Readonly<{ kind: "FAILED"; code: string }>;
|
||||
|
||||
export type ServiceWorkerActivationOutcome =
|
||||
| Readonly<{ kind: "ACTIVATED_RELOAD_REQUIRED" }>
|
||||
| Readonly<{ kind: "BLOCKED_DIRTY_CLIENT" }>
|
||||
| Readonly<{ kind: "CLIENT_DRAIN_TIMEOUT" }>
|
||||
| Readonly<{ kind: "NO_WAITING_WORKER" }>
|
||||
| Readonly<{ kind: "PROTOCOL_MISMATCH" }>
|
||||
| Readonly<{ kind: "FAILED"; code: string }>;
|
||||
|
||||
export type ServiceWorkerResetOutcome =
|
||||
| Readonly<{ kind: "RESET"; cachesDeleted: number }>
|
||||
| Readonly<{ kind: "NOT_CONTROLLED" }>
|
||||
| Readonly<{ kind: "PROTOCOL_MISMATCH" }>
|
||||
| Readonly<{ kind: "FAILED"; code: string }>;
|
||||
|
||||
export type ServiceWorkerRemovalOutcome =
|
||||
| Readonly<{ kind: "ABSENT" }>
|
||||
| Readonly<{ kind: "UNREGISTERED" }>
|
||||
| Readonly<{ kind: "PURGED"; cachesDeleted: number; metadataDeleted: number }>
|
||||
| Readonly<{ kind: "OWNERSHIP_MISMATCH" }>
|
||||
| Readonly<{ kind: "FAILED"; operation: "LOOKUP" | "UNREGISTER" | "PURGE" }>;
|
||||
|
||||
export interface ServiceWorkerRuntimeHost {
|
||||
start(): Promise<ServiceWorkerStartOutcome>;
|
||||
requestActivation(): Promise<ServiceWorkerActivationOutcome>;
|
||||
resetOwnedCaches(): Promise<ServiceWorkerResetOutcome>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user