chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
@@ -0,0 +1,134 @@
import type {
AuthSessionPort,
CredentialPatch,
CredentialRequestBinding,
SessionState,
} from "../../application/ports/auth-session-port.ts";
export type ExternalSessionOwner = Readonly<{
readState(): SessionState;
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
attachCredential(binding: CredentialRequestBinding): Promise<CredentialPatch>;
recoverSession(): Promise<"restored" | "no-session">;
notifyUnauthenticated(): void;
}>;
const ALLOWED_CREDENTIAL_HEADERS = new Set([
"authorization",
"x-csrf-token",
]);
const MAX_HEADER_VALUE_BYTES = 8_192;
export function validateCredentialPatch(value: unknown): CredentialPatch {
if (!value || typeof value !== "object") {
throw new TypeError("Auth owner returned an invalid credential patch");
}
const headers = (value as Record<string, unknown>).headers;
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
throw new TypeError("Auth owner returned an invalid credential patch");
}
const projected: Record<string, string> = {};
for (const [name, headerValue] of Object.entries(headers)) {
const normalizedName = name.toLowerCase();
if (
!ALLOWED_CREDENTIAL_HEADERS.has(normalizedName) ||
typeof headerValue !== "string" ||
headerValue.length === 0 ||
new TextEncoder().encode(headerValue).byteLength > MAX_HEADER_VALUE_BYTES ||
/[\r\n]/.test(headerValue)
) {
throw new TypeError("Auth owner returned a forbidden credential patch");
}
projected[normalizedName] = headerValue;
}
return Object.freeze({ headers: Object.freeze(projected) });
}
export function createExternalAuthSessionAdapter(
owner: ExternalSessionOwner,
): AuthSessionPort {
return Object.freeze({
getState: () => owner.readState(),
subscribe: (listener) => owner.subscribe(listener),
beginSignIn: (returnTo) => owner.beginSignIn(returnTo),
signOut: () => owner.signOut(),
async credentialPatch(binding) {
return validateCredentialPatch(await owner.attachCredential(binding));
},
async recover() {
const result = await owner.recoverSession();
if (result !== "restored" && result !== "no-session") {
throw new TypeError("Auth owner returned an invalid recovery state");
}
return result;
},
onUnauthenticated: () => owner.notifyUnauthenticated(),
});
}
const EMPTY_PATCH = Object.freeze({ headers: Object.freeze({}) });
export function createAnonymousSessionAdapter(): AuthSessionPort {
return createExternalAuthSessionAdapter({
readState: () => "unauthenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async () => EMPTY_PATCH,
recoverSession: async () => "no-session",
notifyUnauthenticated: () => {},
});
}
export type DemoSessionAdapter = AuthSessionPort &
Readonly<{ setState(next: SessionState): void }>;
export function createDemoSessionAdapter(
initialState: SessionState = "unauthenticated",
): DemoSessionAdapter {
let state = initialState;
const listeners = new Set<() => void>();
const setState = (next: SessionState) => {
state = next;
for (const listener of listeners) listener();
};
return Object.freeze({
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async beginSignIn() {
setState("authenticated");
},
async signOut() {
setState("unauthenticated");
},
credentialPatch: async () => EMPTY_PATCH,
async recover() {
if (state === "recovery-pending") {
setState("authenticated");
return "restored";
}
return "no-session";
},
onUnauthenticated: () => setState("unauthenticated"),
setState,
});
}
export function createUnavailableSessionAdapter(): AuthSessionPort {
return Object.freeze({
getState: () => "integration-failed",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
credentialPatch: async () => {
throw new TypeError("External session integration is unavailable");
},
recover: async () => "no-session" as const,
onUnauthenticated: () => {},
});
}
@@ -0,0 +1,6 @@
export {
createStorageDurabilityAdapter,
type StorageManagerFacade,
type StoragePressurePolicy,
type UserActivationFacade,
} from "./storage-manager-adapter.ts";
+101
View File
@@ -0,0 +1,101 @@
import type {
BrowserDataFailure,
BrowserDataFailureCode,
BrowserDataObservation,
BrowserDataObserver,
BrowserDataOperation,
BrowserDataRecovery,
BrowserDataResult,
} from "../../application/ports/browser-file-storage/shared.ts";
export function browserDataSuccess<Value>(
value: Value,
): BrowserDataResult<Value> {
return Object.freeze({ ok: true, value });
}
export function browserDataFailure(
code: BrowserDataFailureCode,
operation: BrowserDataOperation,
options: Readonly<{
retryable?: boolean;
recovery?: BrowserDataRecovery;
}> = {},
): BrowserDataResult<never> {
const error: BrowserDataFailure = Object.freeze({
code,
operation,
retryable: options.retryable ?? false,
recovery: options.recovery ?? "NONE",
});
return Object.freeze({ ok: false, error });
}
export function abortedResult(
signal: AbortSignal | undefined,
operation: BrowserDataOperation,
): BrowserDataResult<never> | null {
return signal?.aborted
? browserDataFailure("ABORTED", operation)
: null;
}
export function mapBrowserDataException(
error: unknown,
operation: BrowserDataOperation,
): BrowserDataResult<never> {
if (!(error instanceof DOMException)) {
return browserDataFailure("UNAVAILABLE", operation, {
retryable: true,
recovery: "RETRY",
});
}
switch (error.name) {
case "AbortError":
return browserDataFailure("ABORTED", operation);
case "ConstraintError":
return browserDataFailure("CONFLICT", operation, {
recovery: "REOPEN",
});
case "DataCloneError":
case "DataError":
return browserDataFailure("CORRUPT_DATA", operation);
case "NotAllowedError":
case "SecurityError":
return browserDataFailure("PERMISSION_DENIED", operation);
case "NotFoundError":
return browserDataFailure("NOT_FOUND", operation);
case "NotReadableError":
return browserDataFailure("NOT_READABLE", operation, {
retryable: true,
recovery: "REOPEN",
});
case "QuotaExceededError":
case "NS_ERROR_DOM_QUOTA_REACHED":
return browserDataFailure("QUOTA_EXCEEDED", operation, {
retryable: true,
recovery: "READ_ONLY",
});
case "VersionError":
return browserDataFailure("MIGRATION_FAILED", operation, {
recovery: "READ_ONLY",
});
default:
return browserDataFailure("UNAVAILABLE", operation, {
retryable: true,
recovery: "RETRY",
});
}
}
export function observeBrowserData(
observer: BrowserDataObserver | undefined,
observation: BrowserDataObservation,
): void {
try {
observer?.record(Object.freeze({ ...observation }));
} catch {
// Capability correctness is independent from best-effort observation.
}
}
@@ -0,0 +1,250 @@
import type {
StorageDurabilityPort,
StorageEstimate,
} from "../../application/ports/browser-file-storage/storage-durability-port.ts";
import {
abortedResult,
browserDataFailure,
browserDataSuccess,
mapBrowserDataException,
} from "./result.ts";
export type StorageManagerFacade = Readonly<{
estimate(): Promise<Readonly<{ usage?: number; quota?: number }>>;
persisted?(): Promise<boolean>;
persist?(): Promise<boolean>;
}>;
export type StoragePressurePolicy = Readonly<{
pressureRatio: number;
criticalRatio: number;
}>;
export type UserActivationFacade = Readonly<{ isActive: boolean }>;
const DEFAULT_PRESSURE_POLICY: StoragePressurePolicy = Object.freeze({
pressureRatio: 0.7,
criticalRatio: 0.85,
});
const STORAGE_INSPECTION_ABORTED = Symbol("storage-inspection-aborted");
export function createStorageDurabilityAdapter(
manager: StorageManagerFacade | undefined,
policy: StoragePressurePolicy = DEFAULT_PRESSURE_POLICY,
userActivation: UserActivationFacade | undefined =
globalThis.navigator?.userActivation,
): StorageDurabilityPort {
const policySnapshot = snapshotPressurePolicy(policy);
const managerSnapshot = snapshotStorageManager(manager);
return Object.freeze({
async inspect(signal?: AbortSignal) {
const aborted = abortedResult(signal, "STORAGE_ESTIMATE");
if (aborted) return aborted;
if (!managerSnapshot) {
return browserDataFailure("UNSUPPORTED", "STORAGE_ESTIMATE", {
recovery: "ONLINE_ONLY",
});
}
try {
const inspected = await awaitStorageInspection(
Promise.all([
managerSnapshot.estimate(),
inspectPersistenceState(managerSnapshot),
]),
signal,
);
if (inspected === STORAGE_INSPECTION_ABORTED) {
return browserDataFailure("ABORTED", "STORAGE_ESTIMATE");
}
const [estimate, persisted] = inspected;
const usageBytes = finiteNonNegative(estimate.usage);
const quotaBytes = finiteNonNegative(estimate.quota);
return browserDataSuccess<StorageEstimate>(
Object.freeze({
usageBytes,
quotaBytes,
persisted,
pressure: storagePressure(
usageBytes,
quotaBytes,
policySnapshot,
),
}),
);
} catch (error) {
return mapBrowserDataException(error, "STORAGE_ESTIMATE");
}
},
async requestPersistence(
input: Parameters<StorageDurabilityPort["requestPersistence"]>[0],
) {
const aborted = abortedResult(input.signal, "STORAGE_PERSIST");
if (aborted) return aborted;
if (
input.userInitiated !== true ||
input.reason !== "PROTECT_UNSYNCED_USER_DATA" ||
userActivation?.isActive !== true
) {
return browserDataFailure(
input.userInitiated !== true ||
input.reason !== "PROTECT_UNSYNCED_USER_DATA"
? "POLICY_REJECTED"
: "PERMISSION_DENIED",
"STORAGE_PERSIST",
);
}
if (!managerSnapshot?.persist) {
return browserDataFailure("UNSUPPORTED", "STORAGE_PERSIST", {
recovery: "ONLINE_ONLY",
});
}
try {
const granted = await managerSnapshot.persist();
if (typeof granted !== "boolean") {
return browserDataFailure("UNAVAILABLE", "STORAGE_PERSIST", {
retryable: true,
recovery: "RETRY",
});
}
// persist() cannot be rolled back. Once invoked, its resolved browser
// truth wins even if the caller aborts while the prompt is pending.
return browserDataSuccess<"GRANTED" | "DENIED">(
granted ? "GRANTED" : "DENIED",
);
} catch (error) {
return mapBrowserDataException(error, "STORAGE_PERSIST");
}
},
});
}
function snapshotStorageManager(
manager: StorageManagerFacade | undefined,
): StorageManagerFacade | undefined {
if (!manager) return undefined;
const estimate = manager.estimate;
const persisted = manager.persisted;
const persist = manager.persist;
if (
typeof estimate !== "function" ||
(persisted !== undefined && typeof persisted !== "function") ||
(persist !== undefined && typeof persist !== "function")
) {
throw new TypeError("StorageManager facade is invalid.");
}
return Object.freeze({
estimate: estimate.bind(manager),
...(persisted
? { persisted: persisted.bind(manager) }
: {}),
...(persist ? { persist: persist.bind(manager) } : {}),
});
}
function snapshotPressurePolicy(
policy: StoragePressurePolicy,
): StoragePressurePolicy {
const snapshot = Object.freeze({
pressureRatio: policy.pressureRatio,
criticalRatio: policy.criticalRatio,
});
assertPressurePolicy(snapshot);
return snapshot;
}
async function awaitStorageInspection<Value>(
inspection: Promise<Value>,
signal: AbortSignal | undefined,
): Promise<Value | typeof STORAGE_INSPECTION_ABORTED> {
if (!signal) return await inspection;
if (signal.aborted) {
// The native calls have already been invoked. Consume a later rejection
// even though the caller no longer waits for their result.
void inspection.catch(() => undefined);
return STORAGE_INSPECTION_ABORTED;
}
return await new Promise<Value | typeof STORAGE_INSPECTION_ABORTED>(
(resolve, reject) => {
let settled = false;
const finish = (
outcome:
| Readonly<{ kind: "VALUE"; value: Value }>
| Readonly<{ kind: "ABORTED" }>
| Readonly<{ kind: "ERROR"; error: unknown }>,
): void => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
if (outcome.kind === "VALUE") {
resolve(outcome.value);
} else if (outcome.kind === "ABORTED") {
resolve(STORAGE_INSPECTION_ABORTED);
} else {
reject(outcome.error);
}
};
const onAbort = (): void => finish({ kind: "ABORTED" });
signal.addEventListener("abort", onAbort, { once: true });
inspection.then(
(value) => finish({ kind: "VALUE", value }),
(error: unknown) => finish({ kind: "ERROR", error }),
);
if (signal.aborted) onAbort();
},
);
}
async function inspectPersistenceState(
manager: StorageManagerFacade,
): Promise<boolean | null> {
if (!manager.persisted) return null;
try {
const persisted = await manager.persisted();
return typeof persisted === "boolean" ? persisted : null;
} catch {
return null;
}
}
function finiteNonNegative(value: number | undefined): number | null {
return typeof value === "number" &&
Number.isSafeInteger(value) &&
value >= 0
? value
: null;
}
function storagePressure(
usageBytes: number | null,
quotaBytes: number | null,
policy: StoragePressurePolicy,
): StorageEstimate["pressure"] {
if (
usageBytes === null ||
quotaBytes === null ||
quotaBytes === 0
) {
return "UNKNOWN";
}
const ratio = usageBytes / quotaBytes;
if (ratio >= policy.criticalRatio) return "CRITICAL";
if (ratio >= policy.pressureRatio) return "PRESSURE";
return "NORMAL";
}
function assertPressurePolicy(policy: StoragePressurePolicy): void {
if (
!Number.isFinite(policy.pressureRatio) ||
!Number.isFinite(policy.criticalRatio) ||
policy.pressureRatio <= 0 ||
policy.criticalRatio > 1 ||
policy.pressureRatio >= policy.criticalRatio
) {
throw new TypeError("Storage pressure policy is invalid.");
}
}
@@ -0,0 +1,697 @@
import type {
FilePolicyReference,
FilePickerPort,
FileSelectionLimitReduction,
FileSelectionOutcome,
LocalFileRef,
} from "../../application/ports/browser-file-storage/file.ts";
import type { BrowserDataResult } from "../../application/ports/browser-file-storage/shared.ts";
import {
abortedResult,
browserDataFailure,
browserDataSuccess,
mapBrowserDataException,
} from "../browser-file-storage/result.ts";
import {
BrowserFileVault,
type SystemFileHandle,
} from "./browser-file-vault.ts";
import {
byteBucket,
observeBrowserFile,
type BrowserFileObserver,
} from "./file-observer.ts";
import type { RegisteredFileSelectionPolicy } from "./file-policy.ts";
import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts";
type UserActivationState = Readonly<{ isActive: boolean }>;
type PickerScheduler = Readonly<{
setTimeout(callback: () => void, delayMs: number): unknown;
clearTimeout(handle: unknown): void;
}>;
type InputEventDependencies = Readonly<{
add(
type: string,
listener: EventListener,
options?: AddEventListenerOptions,
): void;
remove(type: string, listener: EventListener): void;
getAttribute(name: string): string | null;
activate(): void;
}>;
type WindowEventDependencies = Readonly<{
add(type: "focus", listener: EventListener): void;
remove(type: "focus", listener: EventListener): void;
}>;
interface DisposableFilePicker extends FilePickerPort {
dispose(): void;
}
/**
* Focus can return before some engines dispatch the file input change event.
* This grace keeps the fallback from misclassifying a real selection as a
* dismissal. The native cancel event remains authoritative and immediate.
*/
export const DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS = 1_000;
export type NativeInputFilePickerOptions = Readonly<{
/**
* A connected, labelled input owned by presentation. The adapter does not
* create an inaccessible hidden control.
*/
input: HTMLInputElement;
vault: BrowserFileVault;
policies: BrowserFilePolicyRegistry;
window?: Pick<Window, "addEventListener" | "removeEventListener">;
userActivation?: UserActivationState;
scheduler?: PickerScheduler;
focusFallbackGraceMs?: number;
/** @deprecated Use focusFallbackGraceMs. */
cancelFallbackDelayMs?: number;
systemOpenPickerSupported?: boolean;
systemSavePickerSupported?: boolean;
observer?: BrowserFileObserver;
}>;
/**
* Canonical cross-browser picker. select() must be called directly from the
* input's labelled button/keyboard activation.
*/
export class NativeInputFilePicker implements DisposableFilePicker {
readonly support;
readonly #input: HTMLInputElement;
readonly #captureFiles: BrowserFileVault["captureFiles"];
readonly #releaseFile: BrowserFileVault["release"];
readonly #resolveSelection:
BrowserFilePolicyRegistry["resolveSelection"];
readonly #inputEvents: InputEventDependencies;
readonly #windowEvents: WindowEventDependencies | undefined;
readonly #userActivation: UserActivationState | undefined;
readonly #scheduler: PickerScheduler;
readonly #focusFallbackGraceMs: number;
readonly #observer: BrowserFileObserver | undefined;
#pending = false;
#disposed = false;
#abortPending: (() => void) | undefined;
constructor(options: NativeInputFilePickerOptions) {
this.#input = options.input;
this.#captureFiles =
options.vault.captureFiles.bind(options.vault);
this.#releaseFile = options.vault.release.bind(options.vault);
this.#resolveSelection =
options.policies.resolveSelection.bind(options.policies);
const addInputEvent = options.input.addEventListener;
const removeInputEvent = options.input.removeEventListener;
const getInputAttribute = options.input.getAttribute;
const showPicker = options.input.showPicker;
const click = options.input.click;
if (
typeof addInputEvent !== "function" ||
typeof removeInputEvent !== "function" ||
typeof getInputAttribute !== "function" ||
(typeof showPicker !== "function" &&
typeof click !== "function")
) {
throw new TypeError("Native file input API is invalid.");
}
this.#inputEvents = Object.freeze({
add: addInputEvent.bind(options.input),
remove: removeInputEvent.bind(options.input),
getAttribute: getInputAttribute.bind(options.input),
activate:
typeof showPicker === "function"
? showPicker.bind(options.input)
: click.bind(options.input),
});
const windowHost = options.window ?? globalThis.window;
if (windowHost) {
const addWindowEvent = windowHost.addEventListener;
const removeWindowEvent = windowHost.removeEventListener;
if (
typeof addWindowEvent !== "function" ||
typeof removeWindowEvent !== "function"
) {
throw new TypeError("Native picker window API is invalid.");
}
this.#windowEvents = Object.freeze({
add: addWindowEvent.bind(windowHost),
remove: removeWindowEvent.bind(windowHost),
});
} else {
this.#windowEvents = undefined;
}
this.#userActivation =
options.userActivation ?? globalThis.navigator?.userActivation;
const scheduler =
options.scheduler ??
({
setTimeout: (callback: () => void, delayMs: number) =>
globalThis.setTimeout(callback, delayMs),
clearTimeout: (handle: unknown) =>
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
),
} satisfies PickerScheduler);
if (
typeof scheduler.setTimeout !== "function" ||
typeof scheduler.clearTimeout !== "function"
) {
throw new TypeError("Native picker scheduler is invalid.");
}
this.#scheduler = Object.freeze({
setTimeout: scheduler.setTimeout.bind(scheduler),
clearTimeout: scheduler.clearTimeout.bind(scheduler),
});
if (
options.focusFallbackGraceMs !== undefined &&
options.cancelFallbackDelayMs !== undefined &&
options.focusFallbackGraceMs !== options.cancelFallbackDelayMs
) {
throw new TypeError("Native picker focus grace is ambiguous.");
}
this.#focusFallbackGraceMs =
options.focusFallbackGraceMs ??
options.cancelFallbackDelayMs ??
DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS;
this.#observer = options.observer;
this.support = Object.freeze({
nativeInput: true as const,
systemOpenPicker: options.systemOpenPickerSupported ?? false,
systemSavePicker: options.systemSavePickerSupported ?? false,
});
if (
!Number.isSafeInteger(this.#focusFallbackGraceMs) ||
this.#focusFallbackGraceMs < 0
) {
throw new TypeError("Native picker cancel fallback delay is invalid.");
}
}
async select(input: {
policy: FilePolicyReference;
limits?: FileSelectionLimitReduction;
signal?: AbortSignal;
}): Promise<BrowserDataResult<FileSelectionOutcome>> {
let request: SelectionRequestSnapshot;
try {
request = snapshotSelectionRequest(input);
} catch {
return this.#finishObservation(
browserDataFailure("INVALID_INPUT", "FILE_SELECT"),
);
}
if (this.#disposed) {
return this.#finishObservation(
browserDataFailure("UNAVAILABLE", "FILE_SELECT"),
);
}
const cancelled = abortedResult(request.signal, "FILE_SELECT");
if (cancelled) return this.#finishObservation(cancelled);
const resolvedPolicy = this.#resolveSelection(
request.policy,
request.limits,
);
if (!resolvedPolicy.ok) {
return this.#finishObservation(resolvedPolicy);
}
const policy = resolvedPolicy.value;
if (
!isUsableFileInput(
this.#input,
this.#inputEvents.getAttribute,
)
) {
return this.#finishObservation(
browserDataFailure("INVALID_INPUT", "FILE_SELECT"),
);
}
if (this.#pending) {
return this.#finishObservation(
browserDataFailure("BLOCKED", "FILE_SELECT"),
);
}
if (this.#userActivation && !this.#userActivation.isActive) {
return this.#finishObservation(
browserDataFailure("PERMISSION_DENIED", "FILE_SELECT"),
);
}
this.#pending = true;
try {
const result =
await new Promise<BrowserDataResult<FileSelectionOutcome>>(
(resolve) => {
let settled = false;
let focusTimer: unknown;
const signal = request.signal;
const finish = (
outcome: BrowserDataResult<FileSelectionOutcome>,
): void => {
if (settled) return;
settled = true;
this.#inputEvents.remove("change", onChange);
this.#inputEvents.remove("cancel", onCancel);
signal?.removeEventListener("abort", onAbort);
this.#windowEvents?.remove("focus", onWindowFocus);
if (focusTimer !== undefined) {
this.#scheduler.clearTimeout(focusTimer);
}
if (this.#abortPending === abortPending) {
this.#abortPending = undefined;
}
resolve(outcome);
};
const dismiss = (): void =>
finish(
browserDataSuccess(
Object.freeze({ kind: "DISMISSED" as const }),
),
);
const onChange = (): void => {
const files = this.#input.files;
if (!files || files.length === 0) {
dismiss();
return;
}
const captured = this.#captureFiles(
files,
policy,
"NATIVE_INPUT",
);
finish(
captured.ok
? browserDataSuccess(
Object.freeze({
kind: "SELECTED" as const,
files: captured.value,
}),
)
: captured,
);
};
const onCancel = (): void => dismiss();
const onAbort = (): void =>
finish(browserDataFailure("ABORTED", "FILE_SELECT"));
const abortPending = (): void =>
finish(browserDataFailure("ABORTED", "FILE_SELECT"));
const onWindowFocus = (): void => {
if (settled || focusTimer !== undefined) return;
focusTimer = this.#scheduler.setTimeout(
() => {
focusTimer = undefined;
if (settled) return;
// Some engines expose FileList before dispatching change.
// Prefer the selected files over a synthetic dismissal.
if ((this.#input.files?.length ?? 0) > 0) {
onChange();
return;
}
dismiss();
},
this.#focusFallbackGraceMs,
);
};
this.#inputEvents.add("change", onChange, { once: true });
this.#inputEvents.add("cancel", onCancel, { once: true });
signal?.addEventListener("abort", onAbort, { once: true });
this.#windowEvents?.add("focus", onWindowFocus);
this.#abortPending = abortPending;
try {
this.#input.accept = pickerAcceptValue(policy);
this.#input.multiple = policy.multiple;
// Allows the same file to produce a new change event.
this.#input.value = "";
this.#inputEvents.activate();
} catch (error) {
finish(mapBrowserDataException(error, "FILE_SELECT"));
}
},
);
return this.#finishObservation(result);
} catch (error) {
return this.#finishObservation(
mapBrowserDataException(error, "FILE_SELECT"),
);
} finally {
this.#pending = false;
}
}
release(ref: LocalFileRef): void {
this.#releaseFile(ref);
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#abortPending?.();
try {
this.#input.value = "";
} catch {
// Some test doubles or constrained DOM hosts expose a readonly value.
}
}
#finishObservation(
result: BrowserDataResult<FileSelectionOutcome>,
): BrowserDataResult<FileSelectionOutcome> {
if (result.ok) {
const dismissed = result.value.kind === "DISMISSED";
const bytes =
result.value.kind === "SELECTED"
? result.value.files.reduce(
(total, candidate) => total + candidate.sizeBytes,
0,
)
: null;
observeBrowserFile(this.#observer, {
operation: "FILE_SELECT",
outcome: dismissed ? "DISMISSED" : "SUCCESS",
byteBucket: byteBucket(bytes),
});
} else {
observeBrowserFile(this.#observer, {
operation: "FILE_SELECT",
outcome: "FAILED",
failureCode: result.error.code,
});
}
return result;
}
}
export type SystemOpenPickerAcceptType = Readonly<{
description?: string;
accept: Readonly<Record<string, readonly string[]>>;
}>;
export type SystemOpenPickerOptions = Readonly<{
multiple: boolean;
excludeAcceptAllOption: boolean;
types: readonly SystemOpenPickerAcceptType[];
}>;
export type SystemOpenPicker = (
options: SystemOpenPickerOptions,
) => Promise<readonly SystemFileHandle[]>;
export type EnhancedFilePickerOptions = Readonly<{
showOpenFilePicker: SystemOpenPicker;
vault: BrowserFileVault;
policies: BrowserFilePolicyRegistry;
userActivation?: UserActivationState;
systemSavePickerSupported?: boolean;
observer?: BrowserFileObserver;
}>;
/**
* Progressive enhancement. Failure never opens the native fallback in the
* same activation; presentation may offer a baseline button for the next
* explicit user action.
*/
export class EnhancedFilePicker implements DisposableFilePicker {
readonly support;
readonly #showOpenFilePicker: SystemOpenPicker;
readonly #captureHandles: BrowserFileVault["captureHandles"];
readonly #releaseFile: BrowserFileVault["release"];
readonly #resolveSelection:
BrowserFilePolicyRegistry["resolveSelection"];
readonly #userActivation: UserActivationState | undefined;
readonly #observer: BrowserFileObserver | undefined;
#pending = false;
#disposed = false;
#abortPending: (() => void) | undefined;
constructor(options: EnhancedFilePickerOptions) {
if (typeof options.showOpenFilePicker !== "function") {
throw new TypeError("Enhanced file picker API is invalid.");
}
this.#showOpenFilePicker =
options.showOpenFilePicker.bind(options);
this.#captureHandles =
options.vault.captureHandles.bind(options.vault);
this.#releaseFile = options.vault.release.bind(options.vault);
this.#resolveSelection =
options.policies.resolveSelection.bind(options.policies);
this.#userActivation =
options.userActivation ?? globalThis.navigator?.userActivation;
this.#observer = options.observer;
this.support = Object.freeze({
nativeInput: true as const,
systemOpenPicker: true,
systemSavePicker: options.systemSavePickerSupported ?? false,
});
}
async select(input: {
policy: FilePolicyReference;
limits?: FileSelectionLimitReduction;
signal?: AbortSignal;
}): Promise<BrowserDataResult<FileSelectionOutcome>> {
let request: SelectionRequestSnapshot;
try {
request = snapshotSelectionRequest(input);
} catch {
return this.#observe(
browserDataFailure("INVALID_INPUT", "FILE_SELECT"),
);
}
if (this.#disposed) {
return this.#observe(
browserDataFailure("UNAVAILABLE", "FILE_SELECT"),
);
}
const cancelled = abortedResult(request.signal, "FILE_SELECT");
if (cancelled) return this.#observe(cancelled);
const resolvedPolicy = this.#resolveSelection(
request.policy,
request.limits,
);
if (!resolvedPolicy.ok) return this.#observe(resolvedPolicy);
const policy = resolvedPolicy.value;
if (this.#pending) {
return this.#observe(
browserDataFailure("BLOCKED", "FILE_SELECT"),
);
}
if (this.#userActivation && !this.#userActivation.isActive) {
return this.#observe(
browserDataFailure("PERMISSION_DENIED", "FILE_SELECT"),
);
}
this.#pending = true;
try {
// This call intentionally happens before the first await.
const picker = this.#showOpenFilePicker(
systemPickerOptions(policy),
);
const handles = await this.#awaitPickerOrDispose(
picker,
request.signal,
);
const aborted = abortedResult(request.signal, "FILE_SELECT");
if (aborted) return this.#observe(aborted);
if (handles.length === 0) {
return this.#observe(
browserDataSuccess(
Object.freeze({ kind: "DISMISSED" as const }),
),
);
}
const captured = await this.#captureHandles(
handles,
policy,
request.signal ?? new AbortController().signal,
);
return this.#observe(
captured.ok
? browserDataSuccess(
Object.freeze({
kind: "SELECTED" as const,
files: captured.value,
}),
)
: captured,
);
} catch (error) {
if (error instanceof PickerDisposedError) {
return this.#observe(
browserDataFailure("ABORTED", "FILE_SELECT"),
);
}
if (error instanceof DOMException && error.name === "AbortError") {
const aborted = abortedResult(input.signal, "FILE_SELECT");
if (aborted) return this.#observe(aborted);
return this.#observe(
browserDataSuccess(
Object.freeze({ kind: "DISMISSED" as const }),
),
);
}
return this.#observe(
mapBrowserDataException(error, "FILE_SELECT"),
);
} finally {
this.#pending = false;
}
}
release(ref: LocalFileRef): void {
this.#releaseFile(ref);
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#abortPending?.();
}
#awaitPickerOrDispose(
picker: Promise<readonly SystemFileHandle[]>,
signal?: AbortSignal,
): Promise<readonly SystemFileHandle[]> {
if (signal?.aborted) {
return Promise.reject(
new DOMException("Picker aborted", "AbortError"),
);
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback: () => void): void => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", onAbort);
if (this.#abortPending === abortPending) {
this.#abortPending = undefined;
}
callback();
};
const abortPending = (): void =>
finish(() => reject(new PickerDisposedError()));
const onAbort = (): void =>
finish(() =>
reject(new DOMException("Picker aborted", "AbortError")),
);
this.#abortPending = abortPending;
signal?.addEventListener("abort", onAbort, { once: true });
picker.then(
(handles) => finish(() => resolve(handles)),
(error: unknown) => finish(() => reject(error)),
);
});
}
#observe(
result: BrowserDataResult<FileSelectionOutcome>,
): BrowserDataResult<FileSelectionOutcome> {
if (!result.ok) {
observeBrowserFile(this.#observer, {
operation: "FILE_SELECT",
outcome: "FAILED",
failureCode: result.error.code,
});
} else if (result.value.kind === "DISMISSED") {
observeBrowserFile(this.#observer, {
operation: "FILE_SELECT",
outcome: "DISMISSED",
});
} else {
const bytes = result.value.files.reduce(
(total, file) => total + file.sizeBytes,
0,
);
observeBrowserFile(this.#observer, {
operation: "FILE_SELECT",
outcome: "SUCCESS",
byteBucket: byteBucket(bytes),
});
}
return result;
}
}
function pickerAcceptValue(
policy: RegisteredFileSelectionPolicy,
): string {
return policy.accept
.flatMap((rule) => [rule.mediaType, ...rule.extensions])
.join(",");
}
function systemPickerOptions(
policy: RegisteredFileSelectionPolicy,
): SystemOpenPickerOptions {
const types = policy.accept.map((rule) =>
Object.freeze({
accept: Object.freeze({
[rule.mediaType]: Object.freeze([...rule.extensions]),
}),
}),
);
return Object.freeze({
multiple: policy.multiple,
excludeAcceptAllOption: types.length > 0,
types: Object.freeze(types),
});
}
type SelectionRequestSnapshot = Readonly<{
policy: FilePolicyReference;
limits?: FileSelectionLimitReduction;
signal?: AbortSignal;
}>;
function snapshotSelectionRequest(input: {
policy: FilePolicyReference;
limits?: FileSelectionLimitReduction;
signal?: AbortSignal;
}): SelectionRequestSnapshot {
const policy = input.policy;
const limits = input.limits;
const signal = input.signal;
return Object.freeze({
policy,
...(limits
? {
limits: Object.freeze({
...(limits.maxCount !== undefined
? { maxCount: limits.maxCount }
: {}),
...(limits.maxFileBytes !== undefined
? { maxFileBytes: limits.maxFileBytes }
: {}),
...(limits.maxTotalBytes !== undefined
? { maxTotalBytes: limits.maxTotalBytes }
: {}),
}),
}
: {}),
...(signal ? { signal } : {}),
});
}
function isUsableFileInput(
input: HTMLInputElement,
getAttribute: (name: string) => string | null,
): boolean {
if (input.type !== "file" || !input.isConnected) return false;
if ((input.labels?.length ?? 0) > 0) return true;
return (
(getAttribute("aria-label")?.trim().length ?? 0) > 0 ||
(getAttribute("aria-labelledby")?.trim().length ?? 0) > 0
);
}
class PickerDisposedError extends Error {
constructor() {
super("Picker runtime disposed");
this.name = "PickerDisposedError";
}
}
@@ -0,0 +1,526 @@
import type {
DownloadStrategy,
FilePolicyReference,
FileSelectionLimitReduction,
} from "../../application/ports/browser-file-storage/file.ts";
import type {
BrowserDataOperation,
BrowserDataResult,
} from "../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../browser-file-storage/result.ts";
import {
assertFileInspectionPolicy,
assertFileSelectionPolicy,
sanitizeSuggestedFileName,
type RegisteredFileInspectionPolicy,
type RegisteredFileSelectionPolicy,
} from "./file-policy.ts";
export type RegisteredPreviewPolicy = Readonly<{
allowedMediaTypes: readonly string[];
maxPreviewBytes: number;
}>;
export type RegisteredDownloadPolicy = Readonly<{
strategy: DownloadStrategy;
mediaType: string;
safeExtension: string;
maxTransferBytes: number;
maxBufferedBytes: number;
integrity: "OPTIONAL" | "REQUIRED";
}>;
export type BrowserFilePolicyProfile = Readonly<{
reference: FilePolicyReference;
selection?: RegisteredFileSelectionPolicy;
inspection?: RegisteredFileInspectionPolicy;
/**
* Preview is deliberately bound to the inspection policy in this profile.
* A verification receipt from another policy can never be replayed here.
*/
preview?: RegisteredPreviewPolicy;
download?: RegisteredDownloadPolicy;
}>;
export type BrowserFilePolicyRegistryOptions = Readonly<{
profiles: readonly BrowserFilePolicyProfile[];
hardLimits: Readonly<{
maxInspectionBytes: number;
maxRetainedFileBytes: number;
maxPreviewBytes: number;
maxObjectUrlBytes: number;
maxTransferBytes: number;
}>;
}>;
export type ResolvedPreviewPolicy = Readonly<{
verificationPolicyBindingId: string;
allowedMediaTypes: ReadonlySet<string>;
maxPreviewBytes: number;
}>;
export type ResolvedInspectionPolicy =
RegisteredFileInspectionPolicy &
Readonly<{ receiptBindingId: string }>;
export type ResolvedDownloadPolicy = RegisteredDownloadPolicy;
type SnapshotProfile = Readonly<{
receiptBindingId: string;
reference: FilePolicyReference;
selection?: RegisteredFileSelectionPolicy;
inspection?: RegisteredFileInspectionPolicy;
preview?: Readonly<{
allowedMediaTypes: ReadonlySet<string>;
maxPreviewBytes: number;
}>;
download?: RegisteredDownloadPolicy;
}>;
const POLICY_TOKEN = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
const MEDIA_TYPE =
/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
const ISSUED_POLICY_REFERENCES = new WeakSet<object>();
/**
* Creates the only policy reference accepted by the browser-file runtime.
* Product composition should inject the returned object into its narrow
* feature facade instead of exposing the whole registry to presentation.
*/
export function browserFilePolicyReference(
policyKey: string,
intention: string,
): FilePolicyReference {
if (!POLICY_TOKEN.test(policyKey) || !POLICY_TOKEN.test(intention)) {
throw new TypeError("Browser file policy reference is invalid.");
}
const reference = Object.freeze({
policyKey:
policyKey as FilePolicyReference["policyKey"],
intention:
intention as FilePolicyReference["intention"],
});
ISSUED_POLICY_REFERENCES.add(reference);
return reference;
}
/**
* Immutable composition-time registry. It retains no caller-owned object,
* array, Set or byte-pattern reference.
*/
export class BrowserFilePolicyRegistry {
readonly #profiles:
ReadonlyMap<FilePolicyReference, SnapshotProfile>;
constructor(options: BrowserFilePolicyRegistryOptions) {
assertHardLimits(options.hardLimits);
if (
!Array.isArray(options.profiles) ||
options.profiles.length < 1 ||
options.profiles.length > 128
) {
throw new TypeError("Browser file policy registry is invalid.");
}
const profiles =
new Map<FilePolicyReference, SnapshotProfile>();
const semanticKeys = new Set<string>();
for (const input of options.profiles) {
const profile = snapshotProfile(input, options.hardLimits);
const key = referenceKey(profile.reference);
if (semanticKeys.has(key)) {
throw new TypeError("Browser file policy reference is duplicated.");
}
semanticKeys.add(key);
profiles.set(profile.reference, profile);
}
this.#profiles = profiles;
}
resolveSelection(
reference: FilePolicyReference,
reduction?: FileSelectionLimitReduction,
): BrowserDataResult<RegisteredFileSelectionPolicy> {
const profile = this.#resolve(reference, "FILE_SELECT");
if (!profile.ok) return profile;
const policy = profile.value.selection;
if (!policy) {
return browserDataFailure("POLICY_REJECTED", "FILE_SELECT");
}
const maxCount = reducedLimit(
reduction?.maxCount,
policy.maxCount,
"FILE_SELECT",
);
if (!maxCount.ok) return maxCount;
const maxTotalBytes = reducedLimit(
reduction?.maxTotalBytes,
policy.maxTotalBytes,
"FILE_SELECT",
);
if (!maxTotalBytes.ok) return maxTotalBytes;
const maxFileBytes = reducedLimit(
reduction?.maxFileBytes,
Math.min(policy.maxFileBytes, maxTotalBytes.value),
"FILE_SELECT",
);
if (!maxFileBytes.ok) return maxFileBytes;
return browserDataSuccess(
Object.freeze({
...policy,
maxCount: maxCount.value,
maxFileBytes: maxFileBytes.value,
maxTotalBytes: maxTotalBytes.value,
}),
);
}
resolveInspection(
reference: FilePolicyReference,
maxInspectionBytes?: number,
): BrowserDataResult<ResolvedInspectionPolicy> {
const profile = this.#resolve(reference, "FILE_INSPECT");
if (!profile.ok) return profile;
const policy = profile.value.inspection;
if (!policy) {
return browserDataFailure("POLICY_REJECTED", "FILE_INSPECT");
}
const reduced = reducedLimit(
maxInspectionBytes,
policy.maxInspectionBytes,
"FILE_INSPECT",
);
if (!reduced.ok) return reduced;
return browserDataSuccess(
Object.freeze({
...policy,
maxInspectionBytes: reduced.value,
receiptBindingId: profile.value.receiptBindingId,
}),
);
}
resolvePreview(
reference: FilePolicyReference,
maxPreviewBytes?: number,
): BrowserDataResult<ResolvedPreviewPolicy> {
const profile = this.#resolve(reference, "PREVIEW");
if (!profile.ok) return profile;
if (!profile.value.preview || !profile.value.inspection) {
return browserDataFailure("POLICY_REJECTED", "PREVIEW");
}
const reduced = reducedLimit(
maxPreviewBytes,
profile.value.preview.maxPreviewBytes,
"PREVIEW",
);
if (!reduced.ok) return reduced;
return browserDataSuccess(
Object.freeze({
verificationPolicyBindingId:
profile.value.receiptBindingId,
allowedMediaTypes:
new Set(profile.value.preview.allowedMediaTypes),
maxPreviewBytes: reduced.value,
}),
);
}
resolveDownload(
reference: FilePolicyReference,
reductions: Readonly<{
maxTransferBytes?: number;
maxBufferedBytes?: number;
}>,
): BrowserDataResult<ResolvedDownloadPolicy> {
const profile = this.#resolve(reference, "DOWNLOAD");
if (!profile.ok) return profile;
const policy = profile.value.download;
if (!policy) {
return browserDataFailure("POLICY_REJECTED", "DOWNLOAD");
}
const maxTransferBytes = reducedLimit(
reductions.maxTransferBytes,
policy.maxTransferBytes,
"DOWNLOAD",
);
if (!maxTransferBytes.ok) return maxTransferBytes;
const maxBufferedBytes = reducedLimit(
reductions.maxBufferedBytes,
Math.min(policy.maxBufferedBytes, maxTransferBytes.value),
"DOWNLOAD",
);
if (!maxBufferedBytes.ok) return maxBufferedBytes;
return browserDataSuccess(
Object.freeze({
...policy,
maxTransferBytes: maxTransferBytes.value,
maxBufferedBytes: maxBufferedBytes.value,
}),
);
}
#resolve(
reference: FilePolicyReference,
operation: BrowserDataOperation,
): BrowserDataResult<SnapshotProfile> {
try {
if (
typeof reference !== "object" ||
reference === null ||
!ISSUED_POLICY_REFERENCES.has(reference)
) {
return browserDataFailure("POLICY_REJECTED", operation);
}
const profile = this.#profiles.get(reference);
return profile
? browserDataSuccess(profile)
: browserDataFailure("POLICY_REJECTED", operation);
} catch {
return browserDataFailure("POLICY_REJECTED", operation);
}
}
}
function snapshotProfile(
input: BrowserFilePolicyProfile,
hardLimits: BrowserFilePolicyRegistryOptions["hardLimits"],
): SnapshotProfile {
const reference = input.reference;
if (
typeof reference !== "object" ||
reference === null ||
!ISSUED_POLICY_REFERENCES.has(reference) ||
!Object.isFrozen(reference)
) {
throw new TypeError(
"Browser file policy reference was not issued by composition.",
);
}
referenceKey(reference);
if (
input.selection === undefined &&
input.inspection === undefined &&
input.preview === undefined &&
input.download === undefined
) {
throw new TypeError("Browser file policy profile is empty.");
}
const selection = input.selection
? snapshotSelection(input.selection)
: undefined;
if (
selection &&
(selection.maxFileBytes > hardLimits.maxRetainedFileBytes ||
selection.maxTotalBytes > hardLimits.maxRetainedFileBytes)
) {
throw new TypeError("File selection policy exceeds runtime limits.");
}
const inspection = input.inspection
? snapshotInspection(
input.inspection,
hardLimits.maxInspectionBytes,
)
: undefined;
if (input.preview && !inspection) {
throw new TypeError(
"Preview policy requires an inspection policy in the same profile.",
);
}
const preview = input.preview
? snapshotPreview(input.preview, hardLimits.maxPreviewBytes)
: undefined;
const download = input.download
? snapshotDownload(input.download, hardLimits)
: undefined;
return Object.freeze({
receiptBindingId: referenceKey(reference),
reference,
...(selection ? { selection } : {}),
...(inspection ? { inspection } : {}),
...(preview ? { preview } : {}),
...(download ? { download } : {}),
});
}
function snapshotSelection(
input: RegisteredFileSelectionPolicy,
): RegisteredFileSelectionPolicy {
const snapshot = Object.freeze({
policyId: input.policyId,
purpose: input.purpose,
classification: input.classification,
multiple: input.multiple,
maxCount: input.maxCount,
maxFileBytes: input.maxFileBytes,
maxTotalBytes: input.maxTotalBytes,
allowEmpty: input.allowEmpty,
accept: Object.freeze(
input.accept.map((rule) =>
Object.freeze({
mediaType: rule.mediaType.trim().toLowerCase(),
extensions: Object.freeze(
rule.extensions.map((extension) =>
extension.trim().toLowerCase(),
),
),
}),
),
),
});
assertFileSelectionPolicy(snapshot);
return snapshot;
}
function snapshotInspection(
input: RegisteredFileInspectionPolicy,
hardMaxInspectionBytes: number,
): RegisteredFileInspectionPolicy {
const snapshot = Object.freeze({
policyId: input.policyId,
maxInspectionBytes: input.maxInspectionBytes,
acceptedSignatures: Object.freeze(
input.acceptedSignatures.map((rule) =>
Object.freeze({
mediaType: rule.mediaType.trim().toLowerCase(),
extensions: Object.freeze(
rule.extensions.map((extension) =>
extension.trim().toLowerCase(),
),
),
patterns: Object.freeze(
rule.patterns.map((pattern) =>
Object.freeze({
offset: pattern.offset,
bytes: Object.freeze([...pattern.bytes]),
...(pattern.mask
? { mask: Object.freeze([...pattern.mask]) }
: {}),
}),
),
),
}),
),
),
});
assertFileInspectionPolicy(snapshot, hardMaxInspectionBytes);
return snapshot;
}
function snapshotPreview(
input: RegisteredPreviewPolicy,
hardMaxPreviewBytes: number,
): SnapshotProfile["preview"] {
if (
!positiveSafeInteger(input.maxPreviewBytes) ||
input.maxPreviewBytes > hardMaxPreviewBytes ||
!Array.isArray(input.allowedMediaTypes) ||
input.allowedMediaTypes.length < 1 ||
input.allowedMediaTypes.length > 64
) {
throw new TypeError("File preview policy is invalid.");
}
const allowedMediaTypes = new Set<string>();
for (const inputMediaType of input.allowedMediaTypes) {
const mediaType = inputMediaType.trim().toLowerCase();
if (!MEDIA_TYPE.test(mediaType)) {
throw new TypeError("File preview media type is invalid.");
}
allowedMediaTypes.add(mediaType);
}
return Object.freeze({
allowedMediaTypes,
maxPreviewBytes: input.maxPreviewBytes,
});
}
function snapshotDownload(
input: RegisteredDownloadPolicy,
hardLimits: BrowserFilePolicyRegistryOptions["hardLimits"],
): RegisteredDownloadPolicy {
const mediaType = input.mediaType.trim().toLowerCase();
const safeExtension = input.safeExtension.trim().toLowerCase();
if (
![
"BROWSER_MANAGED",
"PROMPT_AND_STREAM",
"BOUNDED_OBJECT_URL",
].includes(input.strategy) ||
!MEDIA_TYPE.test(mediaType) ||
!positiveSafeInteger(input.maxTransferBytes) ||
!positiveSafeInteger(input.maxBufferedBytes) ||
input.maxTransferBytes > hardLimits.maxTransferBytes ||
input.maxBufferedBytes > input.maxTransferBytes ||
(input.strategy === "BOUNDED_OBJECT_URL" &&
input.maxBufferedBytes > hardLimits.maxObjectUrlBytes) ||
!["OPTIONAL", "REQUIRED"].includes(input.integrity)
) {
throw new TypeError("File download policy is invalid.");
}
// Reuse the production filename extension validator.
sanitizeSuggestedFileName("download", { safeExtension });
return Object.freeze({
strategy: input.strategy,
mediaType,
safeExtension,
maxTransferBytes: input.maxTransferBytes,
maxBufferedBytes: input.maxBufferedBytes,
integrity: input.integrity,
});
}
function assertHardLimits(
input: BrowserFilePolicyRegistryOptions["hardLimits"],
): void {
if (
!positiveSafeInteger(input.maxInspectionBytes) ||
!positiveSafeInteger(input.maxRetainedFileBytes) ||
!positiveSafeInteger(input.maxPreviewBytes) ||
!positiveSafeInteger(input.maxObjectUrlBytes) ||
!positiveSafeInteger(input.maxTransferBytes) ||
input.maxObjectUrlBytes > input.maxTransferBytes
) {
throw new TypeError("Browser file policy hard limits are invalid.");
}
}
function reducedLimit(
requested: number | undefined,
configured: number,
operation: BrowserDataOperation,
): BrowserDataResult<number> {
if (requested === undefined) {
return browserDataSuccess(configured);
}
if (!positiveSafeInteger(requested)) {
return browserDataFailure("INVALID_INPUT", operation);
}
if (requested > configured) {
return browserDataFailure("LIMIT_EXCEEDED", operation);
}
return browserDataSuccess(requested);
}
function referenceKey(reference: FilePolicyReference): string {
if (
!reference ||
typeof reference !== "object" ||
!POLICY_TOKEN.test(reference.policyKey) ||
!POLICY_TOKEN.test(reference.intention)
) {
throw new TypeError("Browser file policy reference is invalid.");
}
return JSON.stringify([
reference.policyKey,
reference.intention,
]);
}
function positiveSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
@@ -0,0 +1,895 @@
import type {
FileCandidate,
FileByteSource,
FileContentPort,
FileInspection,
FilePolicyReference,
FileSelectionSource,
FileVerificationReceipt,
LocalFileRef,
} from "../../application/ports/browser-file-storage/file.ts";
import type {
BrowserDataOperation,
BrowserDataResult,
} from "../../application/ports/browser-file-storage/shared.ts";
import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts";
import {
abortedResult,
browserDataFailure,
browserDataSuccess,
mapBrowserDataException,
} from "../browser-file-storage/result.ts";
import {
assertFileInspectionPolicy,
assertFileSelectionPolicy,
findMatchingSignature,
matchesSelectionHint,
normalizedExtension,
signatureMetadataMatches,
signatureWasExpected,
type RegisteredFileSelectionPolicy,
} from "./file-policy.ts";
import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts";
import {
byteBucket,
observeBrowserFile,
type BrowserFileObserver,
} from "./file-observer.ts";
export type SystemFileHandle = Readonly<{
kind: "file";
name: string;
getFile(): Promise<File>;
}>;
export interface NativeFileResolver {
resolveFile(
ref: LocalFileRef,
signal: AbortSignal,
operation?: BrowserDataOperation,
): Promise<BrowserDataResult<File>>;
}
export type VerifiedNativeFile = Readonly<{
file: File;
mediaType: string;
}>;
export interface NativeVerifiedFileResolver {
resolveVerifiedFile(input: {
ref: LocalFileRef;
verificationReceipt: FileVerificationReceipt;
verificationPolicyBindingId: string;
signal: AbortSignal;
}): Promise<BrowserDataResult<VerifiedNativeFile>>;
}
type VaultRecord = Readonly<{
displayName: string;
expectedSize: number;
expectedLastModified: number;
load(): Promise<File>;
}>;
type VerificationRecord = Readonly<{
ref: LocalFileRef;
policyBindingId: string;
mediaType: string;
file: File;
}>;
export type BrowserFileVaultOptions = Readonly<{
policies: BrowserFilePolicyRegistry;
createReference?: () => string;
createVerificationReceipt?: () => string;
hardMaxInspectionBytes?: number;
hardMaxRangeBytes?: number;
hardMaxActiveReferences?: number;
hardMaxRetainedBytes?: number;
observer?: BrowserFileObserver;
}>;
export const DEFAULT_MAX_INSPECTION_BYTES = 64 * 1024;
const DEFAULT_MAX_RANGE_BYTES = 16 * 1024 * 1024;
const DEFAULT_MAX_ACTIVE_REFERENCES = 32;
const DEFAULT_MAX_RETAINED_BYTES = 256 * 1024 * 1024;
/**
* Transient native-file vault. Opaque references are session-only and are
* never derived from a file name or local path.
*/
export class BrowserFileVault
implements FileContentPort, NativeFileResolver, NativeVerifiedFileResolver
{
readonly #records = new Map<LocalFileRef, VaultRecord>();
readonly #verifications = new Map<
FileVerificationReceipt,
VerificationRecord
>();
readonly #verificationReceiptsByRef = new Map<
LocalFileRef,
Set<FileVerificationReceipt>
>();
readonly #createReference: () => string;
readonly #createVerificationReceipt: () => string;
readonly #hardMaxInspectionBytes: number;
readonly #hardMaxRangeBytes: number;
readonly #hardMaxActiveReferences: number;
readonly #hardMaxRetainedBytes: number;
readonly #observer: BrowserFileObserver | undefined;
readonly #resolveInspection:
BrowserFilePolicyRegistry["resolveInspection"];
readonly #lifetime = new AbortController();
#disposed = false;
#retainedBytes = 0;
constructor(options: BrowserFileVaultOptions) {
this.#resolveInspection =
options.policies.resolveInspection.bind(options.policies);
this.#createReference =
options.createReference ??
(() => `file:${globalThis.crypto.randomUUID()}`);
this.#createVerificationReceipt =
options.createVerificationReceipt ??
(() => `verification:${globalThis.crypto.randomUUID()}`);
this.#hardMaxInspectionBytes =
options.hardMaxInspectionBytes ?? DEFAULT_MAX_INSPECTION_BYTES;
this.#hardMaxRangeBytes =
options.hardMaxRangeBytes ?? DEFAULT_MAX_RANGE_BYTES;
this.#hardMaxActiveReferences =
options.hardMaxActiveReferences ??
DEFAULT_MAX_ACTIVE_REFERENCES;
this.#hardMaxRetainedBytes =
options.hardMaxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES;
this.#observer = options.observer;
if (
!isPositiveSafeInteger(this.#hardMaxInspectionBytes) ||
!isPositiveSafeInteger(this.#hardMaxRangeBytes) ||
!isPositiveSafeInteger(this.#hardMaxActiveReferences) ||
!isPositiveSafeInteger(this.#hardMaxRetainedBytes)
) {
throw new TypeError("Browser file vault byte limits are invalid.");
}
}
captureFiles(
files: Iterable<File>,
policy: RegisteredFileSelectionPolicy,
source: FileSelectionSource = "NATIVE_INPUT",
): BrowserDataResult<readonly FileCandidate[]> {
if (this.#disposed) {
return browserDataFailure("UNAVAILABLE", "FILE_SELECT");
}
const nativeFiles = Array.from(files);
const validated = this.#validateSelection(nativeFiles, policy, source);
if (!validated.ok) return validated;
const pending: Array<Readonly<{
candidate: FileCandidate;
record: VaultRecord;
}>> = [];
for (const [index, file] of nativeFiles.entries()) {
const candidate = validated.value[index];
if (!candidate) {
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
}
pending.push({
candidate,
record: Object.freeze({
displayName: file.name,
expectedSize: file.size,
expectedLastModified: file.lastModified,
load: async () => file,
}),
});
}
for (const item of pending) {
this.#retainRecord(item.candidate.ref, item.record);
}
return browserDataSuccess(
Object.freeze(pending.map((item) => item.candidate)),
);
}
async captureHandles(
handles: readonly SystemFileHandle[],
policy: RegisteredFileSelectionPolicy,
signal: AbortSignal,
): Promise<BrowserDataResult<readonly FileCandidate[]>> {
if (this.#disposed) {
return browserDataFailure("UNAVAILABLE", "FILE_SELECT");
}
const cancelled = abortedResult(signal, "FILE_SELECT");
if (cancelled) return cancelled;
try {
assertFileSelectionPolicy(policy);
} catch {
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
}
let handleSnapshots: readonly SystemFileHandle[];
try {
handleSnapshots = snapshotSystemFileHandles(handles);
} catch {
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
}
if (
handleSnapshots.length === 0 ||
handleSnapshots.length > policy.maxCount ||
(!policy.multiple && handleSnapshots.length > 1) ||
this.#records.size + handleSnapshots.length >
this.#hardMaxActiveReferences
) {
return browserDataFailure(
handleSnapshots.length === 0
? "INVALID_INPUT"
: "LIMIT_EXCEEDED",
"FILE_SELECT",
);
}
try {
const files: File[] = [];
for (const handle of handleSnapshots) {
if (handle.kind !== "file") {
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
}
const file = await handle.getFile();
if (file.name !== handle.name) {
return browserDataFailure("STALE_RESULT", "FILE_SELECT", {
recovery: "RESELECT",
});
}
files.push(file);
if (this.#disposed) {
return browserDataFailure("UNAVAILABLE", "FILE_SELECT");
}
const aborted = abortedResult(signal, "FILE_SELECT");
if (aborted) return aborted;
}
const validated = this.#validateSelection(
files,
policy,
"SYSTEM_PICKER",
);
if (!validated.ok) return validated;
for (const [index, handle] of handleSnapshots.entries()) {
const candidate = validated.value[index];
const file = files[index];
if (!candidate || !file) {
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
}
this.#retainRecord(
candidate.ref,
Object.freeze({
displayName: file.name,
expectedSize: file.size,
expectedLastModified: file.lastModified,
load: () => handle.getFile(),
}),
);
}
return validated;
} catch (error) {
return mapBrowserDataException(error, "FILE_SELECT");
}
}
async inspect(input: {
ref: LocalFileRef;
policy: FilePolicyReference;
maxInspectionBytes?: number;
signal: AbortSignal;
}): Promise<BrowserDataResult<FileInspection>> {
if (this.#disposed) {
return this.#observeFailureResult(
browserDataFailure("UNAVAILABLE", "FILE_INSPECT"),
);
}
let request: Readonly<{
ref: LocalFileRef;
policy: FilePolicyReference;
maxInspectionBytes?: number;
signal: AbortSignal;
}>;
try {
const maxInspectionBytes = input.maxInspectionBytes;
request = Object.freeze({
ref: input.ref,
policy: input.policy,
...(maxInspectionBytes !== undefined
? { maxInspectionBytes }
: {}),
signal: input.signal,
});
} catch {
return this.#observeFailureResult(
browserDataFailure("INVALID_INPUT", "FILE_INSPECT"),
);
}
const resolvedPolicy = this.#resolveInspection(
request.policy,
request.maxInspectionBytes,
);
if (!resolvedPolicy.ok) {
return this.#observeFailureResult(resolvedPolicy);
}
const policy = resolvedPolicy.value;
try {
assertFileInspectionPolicy(
policy,
this.#hardMaxInspectionBytes,
);
} catch {
return this.#observeFailureResult(
browserDataFailure("POLICY_REJECTED", "FILE_INSPECT"),
);
}
this.#invalidateVerifications(request.ref);
const resolved = await this.resolveFile(
request.ref,
request.signal,
"FILE_INSPECT",
);
if (!resolved.ok) return this.#observeFailureResult(resolved);
const file = resolved.value;
try {
const headerLength = Math.min(
file.size,
policy.maxInspectionBytes,
);
const header = new Uint8Array(
await file.slice(0, headerLength).arrayBuffer(),
);
if (this.#disposed) {
return this.#observeFailureResult(
browserDataFailure("UNAVAILABLE", "FILE_INSPECT"),
);
}
const cancelled = abortedResult(
request.signal,
"FILE_INSPECT",
);
if (cancelled) return this.#observeFailureResult(cancelled);
const matched = findMatchingSignature(
header,
policy.acceptedSignatures,
);
const expected = signatureWasExpected(
file.name,
normalizedMediaType(file.type),
policy.acceptedSignatures,
);
const signature = matched
? signatureMetadataMatches(
file.name,
normalizedMediaType(file.type),
matched,
)
? ("MATCHED" as const)
: ("MISMATCHED" as const)
: expected
? ("MISMATCHED" as const)
: ("UNKNOWN" as const);
let verificationReceipt: FileVerificationReceipt | null = null;
if (matched && signature === "MATCHED") {
const issued = this.#issueVerification({
ref: request.ref,
policyBindingId: policy.receiptBindingId,
mediaType: matched.mediaType,
file,
});
if (!issued.ok) {
this.#observeFailure("FILE_INSPECT", issued);
return issued;
}
verificationReceipt = issued.value;
}
const inspection = Object.freeze({
byteLength: file.size,
reportedMediaType: normalizedMediaType(file.type),
detectedMediaType: matched?.mediaType ?? null,
normalizedExtension: normalizedExtension(file.name),
signature,
verificationReceipt,
});
this.#observeSuccess("FILE_INSPECT", file.size);
return browserDataSuccess(inspection);
} catch (error) {
const failure = mapBrowserDataException(error, "FILE_INSPECT");
this.#observeFailure("FILE_INSPECT", failure);
return failure;
}
}
async readRange(input: {
ref: LocalFileRef;
offset: number;
length: number;
signal: AbortSignal;
}): Promise<BrowserDataResult<Uint8Array>> {
if (this.#disposed) {
return this.#observeFailureResult(
browserDataFailure("UNAVAILABLE", "FILE_READ"),
);
}
let ref: LocalFileRef;
let offset: number;
let length: number;
let signal: AbortSignal;
try {
ref = input.ref;
offset = input.offset;
length = input.length;
signal = input.signal;
} catch {
return this.#observeFailureResult(
browserDataFailure("INVALID_INPUT", "FILE_READ"),
);
}
if (
!isValidByteLength(offset) ||
!isValidByteLength(length) ||
length > this.#hardMaxRangeBytes ||
!Number.isSafeInteger(offset + length)
) {
return this.#observeFailureResult(
browserDataFailure("LIMIT_EXCEEDED", "FILE_READ"),
);
}
const resolved = await this.resolveFile(
ref,
signal,
"FILE_READ",
);
if (!resolved.ok) return this.#observeFailureResult(resolved);
const file = resolved.value;
if (offset + length > file.size) {
return this.#observeFailureResult(
browserDataFailure("INVALID_INPUT", "FILE_READ"),
);
}
try {
const bytes = new Uint8Array(
await file
.slice(offset, offset + length)
.arrayBuffer(),
);
if (this.#disposed) {
return this.#observeFailureResult(
browserDataFailure("UNAVAILABLE", "FILE_READ"),
);
}
const cancelled = abortedResult(signal, "FILE_READ");
if (cancelled) return this.#observeFailureResult(cancelled);
this.#observeSuccess("FILE_READ", bytes.byteLength);
return browserDataSuccess(bytes);
} catch (error) {
const failure = mapBrowserDataException(error, "FILE_READ");
this.#observeFailure("FILE_READ", failure);
return failure;
}
}
async openSource(input: {
ref: LocalFileRef;
signal: AbortSignal;
}): Promise<BrowserDataResult<FileByteSource>> {
if (this.#disposed) {
return this.#observeFailureResult(
browserDataFailure("UNAVAILABLE", "FILE_READ"),
);
}
let ref: LocalFileRef;
let requestSignal: AbortSignal;
try {
ref = input.ref;
requestSignal = input.signal;
} catch {
return this.#observeFailureResult(
browserDataFailure("INVALID_INPUT", "FILE_READ"),
);
}
const resolved = await this.resolveFile(
ref,
requestSignal,
"FILE_READ",
);
if (!resolved.ok) return this.#observeFailureResult(resolved);
const file = resolved.value;
const expectedLength = file.size;
const vault = this;
const source: FileByteSource = Object.freeze({
byteLength: expectedLength,
async *stream(
signal: AbortSignal,
): AsyncIterable<BrowserDataResult<Uint8Array>> {
let transferred = 0;
const combined = combineAbortSignals(
signal,
vault.#lifetime.signal,
);
try {
for await (const chunk of streamNativeFile(
file,
combined.signal,
)) {
transferred += chunk.byteLength;
yield browserDataSuccess(chunk);
}
if (vault.#disposed) {
const unavailable = browserDataFailure(
"UNAVAILABLE",
"FILE_READ",
);
vault.#observeFailureResult(unavailable);
yield unavailable;
return;
}
vault.#observeSuccess("FILE_READ", transferred);
} catch (error) {
const failure = vault.#disposed
? browserDataFailure("UNAVAILABLE", "FILE_READ")
: mapBrowserDataException(error, "FILE_READ");
vault.#observeFailureResult(failure);
yield failure;
} finally {
combined.release();
}
},
});
return browserDataSuccess(source);
}
async resolveFile(
ref: LocalFileRef,
signal: AbortSignal,
operation: BrowserDataOperation = "FILE_READ",
): Promise<BrowserDataResult<File>> {
if (this.#disposed) {
return browserDataFailure("UNAVAILABLE", operation);
}
const cancelled = abortedResult(signal, operation);
if (cancelled) return cancelled;
const record = this.#records.get(ref);
if (!record) {
return browserDataFailure("NOT_FOUND", operation, {
recovery: "RESELECT",
});
}
try {
const file = await record.load();
if (this.#disposed) {
return browserDataFailure("UNAVAILABLE", operation);
}
const aborted = abortedResult(signal, operation);
if (aborted) return aborted;
if (
file.name !== record.displayName ||
file.size !== record.expectedSize ||
file.lastModified !== record.expectedLastModified
) {
return browserDataFailure("STALE_RESULT", operation, {
recovery: "RESELECT",
});
}
return browserDataSuccess(file);
} catch (error) {
return mapBrowserDataException(error, operation);
}
}
async resolveVerifiedFile(input: {
ref: LocalFileRef;
verificationReceipt: FileVerificationReceipt;
verificationPolicyBindingId: string;
signal: AbortSignal;
}): Promise<BrowserDataResult<VerifiedNativeFile>> {
if (this.#disposed) {
return browserDataFailure("UNAVAILABLE", "PREVIEW");
}
const cancelled = abortedResult(input.signal, "PREVIEW");
if (cancelled) return cancelled;
const verification = this.#verifications.get(
input.verificationReceipt,
);
if (
!verification ||
verification.ref !== input.ref ||
verification.policyBindingId !==
input.verificationPolicyBindingId ||
!this.#records.has(input.ref)
) {
return browserDataFailure("POLICY_REJECTED", "PREVIEW");
}
return browserDataSuccess(
Object.freeze({
file: verification.file,
mediaType: verification.mediaType,
}),
);
}
release(ref: LocalFileRef): void {
this.#invalidateVerifications(ref);
const record = this.#records.get(ref);
if (record && this.#records.delete(ref)) {
this.#retainedBytes -= record.expectedSize;
}
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#lifetime.abort();
this.#verifications.clear();
this.#verificationReceiptsByRef.clear();
this.#records.clear();
this.#retainedBytes = 0;
}
get activeReferenceCount(): number {
return this.#records.size;
}
get activeVerificationCount(): number {
return this.#verifications.size;
}
get retainedByteLength(): number {
return this.#retainedBytes;
}
#validateSelection(
files: readonly File[],
policy: RegisteredFileSelectionPolicy,
source: FileSelectionSource,
): BrowserDataResult<readonly FileCandidate[]> {
try {
assertFileSelectionPolicy(policy);
} catch {
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
}
if (
files.length === 0 ||
files.length > policy.maxCount ||
(!policy.multiple && files.length > 1) ||
this.#records.size + files.length > this.#hardMaxActiveReferences
) {
return browserDataFailure(
files.length === 0 ? "INVALID_INPUT" : "LIMIT_EXCEEDED",
"FILE_SELECT",
);
}
if (!["NATIVE_INPUT", "SYSTEM_PICKER", "DROP"].includes(source)) {
return browserDataFailure("INVALID_INPUT", "FILE_SELECT");
}
const refs = new Set<LocalFileRef>();
const candidates: FileCandidate[] = [];
let totalBytes = 0;
for (const file of files) {
if (
!isValidByteLength(file.size) ||
file.size > policy.maxFileBytes ||
(!policy.allowEmpty && file.size === 0) ||
!Number.isSafeInteger(totalBytes + file.size)
) {
return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT");
}
totalBytes += file.size;
if (totalBytes > policy.maxTotalBytes) {
return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT");
}
const refValue = this.#createReference();
if (!safeOpaqueValue(refValue)) {
return browserDataFailure("UNAVAILABLE", "FILE_SELECT");
}
const ref = refValue as LocalFileRef;
if (refs.has(ref) || this.#records.has(ref)) {
return browserDataFailure("CONFLICT", "FILE_SELECT");
}
refs.add(ref);
const candidate: FileCandidate = Object.freeze({
ref,
displayName: file.name,
sizeBytes: file.size,
reportedMediaType: normalizedMediaType(file.type),
lastModifiedEpochMs: isValidByteLength(file.lastModified)
? file.lastModified
: null,
source,
});
if (!matchesSelectionHint(candidate, policy.accept)) {
return browserDataFailure("POLICY_REJECTED", "FILE_SELECT");
}
candidates.push(candidate);
}
if (
!Number.isSafeInteger(this.#retainedBytes + totalBytes) ||
this.#retainedBytes + totalBytes > this.#hardMaxRetainedBytes
) {
return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT");
}
return browserDataSuccess(Object.freeze(candidates));
}
#issueVerification(record: VerificationRecord):
BrowserDataResult<FileVerificationReceipt> {
const receiptValue = this.#createVerificationReceipt();
if (!safeOpaqueValue(receiptValue)) {
return browserDataFailure("UNAVAILABLE", "FILE_INSPECT");
}
const receipt = receiptValue as FileVerificationReceipt;
if (this.#verifications.has(receipt)) {
return browserDataFailure("CONFLICT", "FILE_INSPECT");
}
this.#verifications.set(receipt, Object.freeze(record));
const receipts =
this.#verificationReceiptsByRef.get(record.ref) ??
new Set<FileVerificationReceipt>();
receipts.add(receipt);
this.#verificationReceiptsByRef.set(record.ref, receipts);
return browserDataSuccess(receipt);
}
#invalidateVerifications(ref: LocalFileRef): void {
const receipts = this.#verificationReceiptsByRef.get(ref);
if (!receipts) return;
for (const receipt of receipts) {
this.#verifications.delete(receipt);
}
this.#verificationReceiptsByRef.delete(ref);
}
#retainRecord(ref: LocalFileRef, record: VaultRecord): void {
this.#records.set(ref, record);
this.#retainedBytes += record.expectedSize;
}
#observeSuccess(operation: BrowserDataOperation, bytes: number): void {
observeBrowserFile(this.#observer, {
operation,
outcome: "SUCCESS",
byteBucket: byteBucket(bytes),
});
}
#observeFailure(
operation: BrowserDataOperation,
failure: BrowserDataResult<never>,
): void {
if (failure.ok) return;
observeBrowserFile(this.#observer, {
operation,
outcome: "FAILED",
failureCode: failure.error.code,
});
}
#observeFailureResult<Value>(
failure: BrowserDataResult<Value>,
): BrowserDataResult<Value> {
if (!failure.ok) {
observeBrowserFile(this.#observer, {
operation: failure.error.operation,
outcome: "FAILED",
failureCode: failure.error.code,
});
}
return failure;
}
}
function snapshotSystemFileHandles(
handles: readonly SystemFileHandle[],
): readonly SystemFileHandle[] {
if (!Array.isArray(handles)) {
throw new TypeError("System file handles are invalid.");
}
return Object.freeze(
handles.map((handle) => {
const kind = handle.kind;
const name = handle.name;
const getFile = handle.getFile;
if (
kind !== "file" ||
typeof name !== "string" ||
name.length === 0 ||
typeof getFile !== "function"
) {
throw new TypeError("System file handle is invalid.");
}
return Object.freeze({
kind,
name,
getFile: getFile.bind(handle),
});
}),
);
}
async function* streamNativeFile(
file: File,
signal: AbortSignal,
): AsyncIterable<Uint8Array> {
if (signal.aborted) throw abortException();
const reader = file.stream().getReader();
const abort = () => {
void reader.cancel(abortException()).catch(() => {});
};
signal.addEventListener("abort", abort, { once: true });
let transferred = 0;
let completed = false;
try {
while (true) {
if (signal.aborted) throw abortException();
const result = await reader.read();
if (signal.aborted) throw abortException();
if (result.done) break;
const chunk = result.value;
if (!(chunk instanceof Uint8Array)) {
throw new DOMException("Unexpected file chunk", "NotReadableError");
}
if (
!Number.isSafeInteger(transferred + chunk.byteLength) ||
transferred + chunk.byteLength > file.size
) {
throw new DOMException("File size changed", "NotReadableError");
}
transferred += chunk.byteLength;
if (chunk.byteLength > 0) yield chunk;
}
if (transferred !== file.size) {
throw new DOMException("File read was incomplete", "NotReadableError");
}
completed = true;
} finally {
signal.removeEventListener("abort", abort);
if (!completed) {
try {
await reader.cancel();
} catch {
// The stream may already be errored or cancelled by AbortSignal.
}
}
reader.releaseLock();
}
}
function normalizedMediaType(value: string): string | null {
const normalized = value.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
function isPositiveSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
function safeOpaqueValue(value: string): boolean {
return /^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value);
}
function combineAbortSignals(
caller: AbortSignal,
lifetime: AbortSignal,
): Readonly<{ signal: AbortSignal; release(): void }> {
const controller = new AbortController();
const abort = (): void => controller.abort();
if (caller.aborted || lifetime.aborted) {
controller.abort();
} else {
caller.addEventListener("abort", abort, { once: true });
lifetime.addEventListener("abort", abort, { once: true });
}
return Object.freeze({
signal: controller.signal,
release(): void {
caller.removeEventListener("abort", abort);
lifetime.removeEventListener("abort", abort);
},
});
}
function abortException(): DOMException {
return new DOMException("Operation aborted", "AbortError");
}
@@ -0,0 +1,245 @@
import type {
DownloadDeliveryPort,
FileContentPort,
FilePickerPort,
TransientPreviewPort,
} from "../../application/ports/browser-file-storage/file.ts";
import {
EnhancedFilePicker,
NativeInputFilePicker,
type NativeInputFilePickerOptions,
type SystemOpenPicker,
} from "./browser-file-picker.ts";
import {
BrowserFileVault,
DEFAULT_MAX_INSPECTION_BYTES,
type BrowserFileVaultOptions,
} from "./browser-file-vault.ts";
import {
BrowserFilePolicyRegistry,
type BrowserFilePolicyProfile,
} from "./browser-file-policy-registry.ts";
import {
createDownloadDeliveryAdapter,
type DownloadDeliveryAdapterOptions,
} from "./download-delivery-adapter.ts";
import type { BrowserFileObserver } from "./file-observer.ts";
import {
BrowserTransientPreview,
ObjectUrlLeaseRegistry,
type ObjectUrlApi,
} from "./object-url-lease.ts";
type UserActivationState = Readonly<{ isActive: boolean }>;
export type BrowserFileRuntimeOptions = Readonly<{
input: HTMLInputElement;
policies: readonly BrowserFilePolicyProfile[];
limits: Readonly<{
hardMaxPreviewBytes: number;
hardMaxObjectUrlBytes: number;
hardMaxTransferBytes: number;
hardMaxActiveFileReferences?: number;
hardMaxRetainedFileBytes?: number;
hardMaxActiveObjectUrls?: number;
hardMaxObjectUrlAggregateBytes?: number;
}>;
download: Omit<
DownloadDeliveryAdapterOptions,
| "objectUrls"
| "observer"
| "policies"
| "userActivation"
| "hardMaxObjectUrlBytes"
| "hardMaxTransferBytes"
>;
showOpenFilePicker?: SystemOpenPicker;
userActivation?: UserActivationState;
observer?: BrowserFileObserver;
objectUrlApi?: ObjectUrlApi;
vault?: Omit<BrowserFileVaultOptions, "observer" | "policies">;
nativePicker?: Pick<
NativeInputFilePickerOptions,
| "window"
| "scheduler"
| "focusFallbackGraceMs"
| "cancelFallbackDelayMs"
>;
hardForbiddenPreviewMediaTypes?: ReadonlySet<string>;
}>;
export type BrowserFileRuntime = Readonly<{
/**
* Canonical cross-browser control. Presentation decides which explicit
* user action invokes this baseline.
*/
baselinePicker: FilePickerPort;
/**
* Optional enhancement. It is never retried through baselinePicker in the
* same user activation.
*/
enhancedPicker: FilePickerPort | null;
content: FileContentPort;
previews: TransientPreviewPort;
downloads: DownloadDeliveryPort;
dispose(): void;
}>;
/**
* Optional feature factory. Nothing imports this from bootstrap, so browser
* file code remains outside the default bundle until a feature composes it.
*/
export function createBrowserFileRuntime(
options: BrowserFileRuntimeOptions,
): BrowserFileRuntime {
const limits = resolveRuntimeLimits(options.limits);
const policies = new BrowserFilePolicyRegistry({
profiles: options.policies,
hardLimits: {
maxInspectionBytes:
options.vault?.hardMaxInspectionBytes ??
DEFAULT_MAX_INSPECTION_BYTES,
maxRetainedFileBytes: limits.hardMaxRetainedFileBytes,
maxPreviewBytes: limits.hardMaxPreviewBytes,
maxObjectUrlBytes: limits.hardMaxObjectUrlBytes,
maxTransferBytes: limits.hardMaxTransferBytes,
},
});
const objectUrls = new ObjectUrlLeaseRegistry(
options.objectUrlApi,
{
hardMaxActiveLeases: limits.hardMaxActiveObjectUrls,
hardMaxSingleLeaseBytes: Math.max(
limits.hardMaxPreviewBytes,
limits.hardMaxObjectUrlBytes,
),
hardMaxAggregateLeaseBytes:
limits.hardMaxObjectUrlAggregateBytes,
},
);
const vault = new BrowserFileVault({
...options.vault,
policies,
hardMaxActiveReferences: limits.hardMaxActiveFileReferences,
hardMaxRetainedBytes: limits.hardMaxRetainedFileBytes,
observer: options.observer,
});
const commonPickerOptions = {
vault,
policies,
userActivation: options.userActivation,
observer: options.observer,
};
const baselinePicker = new NativeInputFilePicker({
...commonPickerOptions,
...options.nativePicker,
input: options.input,
systemOpenPickerSupported:
options.showOpenFilePicker !== undefined,
systemSavePickerSupported:
options.download.showSaveFilePicker !== undefined,
});
const enhancedPicker = options.showOpenFilePicker
? new EnhancedFilePicker({
...commonPickerOptions,
showOpenFilePicker: options.showOpenFilePicker,
systemSavePickerSupported:
options.download.showSaveFilePicker !== undefined,
})
: null;
const previews = new BrowserTransientPreview({
files: vault,
policies,
leases: objectUrls,
hardMaxPreviewBytes: limits.hardMaxPreviewBytes,
hardForbiddenMediaTypes:
options.hardForbiddenPreviewMediaTypes,
observer: options.observer,
});
const downloads = createDownloadDeliveryAdapter({
...options.download,
policies,
objectUrls,
hardMaxObjectUrlBytes: limits.hardMaxObjectUrlBytes,
hardMaxTransferBytes: limits.hardMaxTransferBytes,
observer: options.observer,
userActivation: options.userActivation,
});
let disposed = false;
return Object.freeze({
baselinePicker,
enhancedPicker,
content: vault,
previews,
downloads,
dispose(): void {
if (disposed) return;
disposed = true;
baselinePicker.dispose();
enhancedPicker?.dispose();
downloads.dispose();
previews.dispose();
vault.dispose();
},
});
}
type ResolvedRuntimeLimits = Readonly<{
hardMaxPreviewBytes: number;
hardMaxObjectUrlBytes: number;
hardMaxTransferBytes: number;
hardMaxActiveFileReferences: number;
hardMaxRetainedFileBytes: number;
hardMaxActiveObjectUrls: number;
hardMaxObjectUrlAggregateBytes: number;
}>;
function resolveRuntimeLimits(
limits: BrowserFileRuntimeOptions["limits"],
): ResolvedRuntimeLimits {
const hardMaxSingleObjectUrlBytes = Math.max(
limits.hardMaxPreviewBytes,
limits.hardMaxObjectUrlBytes,
);
const derivedAggregate = Math.min(
Number.MAX_SAFE_INTEGER,
hardMaxSingleObjectUrlBytes * 4,
);
const resolved = Object.freeze({
hardMaxPreviewBytes: limits.hardMaxPreviewBytes,
hardMaxObjectUrlBytes: limits.hardMaxObjectUrlBytes,
hardMaxTransferBytes: limits.hardMaxTransferBytes,
hardMaxActiveFileReferences:
limits.hardMaxActiveFileReferences ?? 32,
hardMaxRetainedFileBytes:
limits.hardMaxRetainedFileBytes ??
limits.hardMaxTransferBytes,
hardMaxActiveObjectUrls:
limits.hardMaxActiveObjectUrls ?? 16,
hardMaxObjectUrlAggregateBytes:
limits.hardMaxObjectUrlAggregateBytes ?? derivedAggregate,
});
if (
!isPositiveSafeInteger(resolved.hardMaxPreviewBytes) ||
!isPositiveSafeInteger(resolved.hardMaxObjectUrlBytes) ||
!isPositiveSafeInteger(resolved.hardMaxTransferBytes) ||
!isPositiveSafeInteger(resolved.hardMaxActiveFileReferences) ||
!isPositiveSafeInteger(resolved.hardMaxRetainedFileBytes) ||
!isPositiveSafeInteger(resolved.hardMaxActiveObjectUrls) ||
!isPositiveSafeInteger(resolved.hardMaxObjectUrlAggregateBytes) ||
resolved.hardMaxObjectUrlBytes >
resolved.hardMaxTransferBytes ||
resolved.hardMaxPreviewBytes >
resolved.hardMaxRetainedFileBytes ||
hardMaxSingleObjectUrlBytes >
resolved.hardMaxObjectUrlAggregateBytes
) {
throw new TypeError("Browser file runtime hard limits are invalid.");
}
return resolved;
}
function isPositiveSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
import type {
BrowserDataObservation,
BrowserDataObserver,
BrowserDataFailureCode,
BrowserDataOperation,
} from "../../application/ports/browser-file-storage/shared.ts";
import { observeBrowserData } from "../browser-file-storage/result.ts";
type BrowserFileObservation = Readonly<{
operation: BrowserDataOperation;
outcome: "SUCCESS" | "DISMISSED" | "FAILED";
failureCode?: BrowserDataFailureCode;
byteBucket?: BrowserDataObservation["byteBucket"];
}>;
/**
* File adapters use the platform BrowserDataObserver as the telemetry SSOT.
* The helper below is the sole mapper from file-local dismissal semantics.
*/
export type BrowserFileObserver = BrowserDataObserver;
export function byteBucket(
byteLength: number | null,
): BrowserFileObservation["byteBucket"] | undefined {
if (byteLength === null || !Number.isSafeInteger(byteLength) || byteLength < 0) {
return undefined;
}
if (byteLength === 0) return "ZERO";
if (byteLength < 1_048_576) return "LT1MIB";
if (byteLength < 10_485_760) return "1_TO_9MIB";
if (byteLength < 104_857_600) return "10_TO_99MIB";
return "GTE100MIB";
}
export function observeBrowserFile(
observer: BrowserFileObserver | undefined,
observation: BrowserFileObservation,
): void {
observeBrowserData(
observer,
Object.freeze({
operation: observation.operation,
outcome:
observation.outcome === "FAILED" ? "FAILED" : "SUCCEEDED",
...(observation.failureCode
? { failureCode: observation.failureCode }
: {}),
...(observation.byteBucket
? { byteBucket: observation.byteBucket }
: {}),
}),
);
}
+441
View File
@@ -0,0 +1,441 @@
import type {
FileCandidate,
} from "../../application/ports/browser-file-storage/file.ts";
import type { PersistableDataClass } from "../../application/ports/browser-file-storage/shared.ts";
import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts";
export type FileAcceptRule = Readonly<{
mediaType: string;
extensions: readonly string[];
}>;
export type RegisteredFileSelectionPolicy = Readonly<{
policyId: string;
purpose: string;
classification: PersistableDataClass;
multiple: boolean;
maxCount: number;
maxFileBytes: number;
maxTotalBytes: number;
allowEmpty: boolean;
accept: readonly FileAcceptRule[];
}>;
export type FileBytePattern = Readonly<{
offset: number;
bytes: readonly number[];
mask?: readonly number[];
}>;
export type FileSignatureRule = Readonly<{
mediaType: string;
extensions: readonly string[];
patterns: readonly FileBytePattern[];
}>;
export type RegisteredFileInspectionPolicy = Readonly<{
policyId: string;
maxInspectionBytes: number;
acceptedSignatures: readonly FileSignatureRule[];
}>;
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/(?:[a-z0-9!#$&^_.+-]+|\*)$/i;
const EXTENSION =
/^\.[a-z0-9][a-z0-9+_-]{0,15}(?:\.[a-z0-9][a-z0-9+_-]{0,15})?$/i;
const POLICY_TOKEN = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
const FILE_SYSTEM_RESERVED = /[<>:"|?*]/g;
const WINDOWS_RESERVED =
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
const DEFAULT_FORBIDDEN_EXTENSIONS = Object.freeze([
".app",
".apk",
".bat",
".cer",
".cmd",
".com",
".cpl",
".deb",
".dmg",
".exe",
".htm",
".html",
".hta",
".inf",
".iso",
".jar",
".js",
".lnk",
".mjs",
".msi",
".pif",
".ps1",
".reg",
".rpm",
".scr",
".sh",
".svg",
".vb",
".vbe",
".vbs",
".wsf",
".wsh",
".xll",
] as const);
export type SuggestedFileNamePolicy = Readonly<{
safeExtension: string;
fallbackBaseName?: string;
maxUtf8Bytes?: number;
forbiddenExtensions?: readonly string[];
}>;
export function assertFileSelectionPolicy(
policy: RegisteredFileSelectionPolicy,
): void {
if (
!POLICY_TOKEN.test(policy.policyId) ||
!POLICY_TOKEN.test(policy.purpose) ||
![
"PUBLIC",
"INTERNAL",
"PERSONAL",
"CONFIDENTIAL",
].includes(policy.classification) ||
!Number.isSafeInteger(policy.maxCount) ||
policy.maxCount < 1 ||
!isValidByteLength(policy.maxFileBytes) ||
!isValidByteLength(policy.maxTotalBytes) ||
policy.maxFileBytes > policy.maxTotalBytes ||
(!policy.allowEmpty &&
(policy.maxFileBytes === 0 || policy.maxTotalBytes === 0)) ||
(!policy.multiple && policy.maxCount !== 1)
) {
throw new TypeError("File selection policy is invalid.");
}
for (const rule of policy.accept) {
assertAcceptRule(rule);
}
}
function assertAcceptRule(rule: FileAcceptRule): void {
if (
!MEDIA_TYPE.test(rule.mediaType) ||
rule.extensions.length === 0 ||
rule.extensions.some((extension) => !EXTENSION.test(extension))
) {
throw new TypeError("File accept rule is invalid.");
}
}
export function assertFileInspectionPolicy(
policy: RegisteredFileInspectionPolicy,
hardMaxInspectionBytes: number,
): void {
if (
!POLICY_TOKEN.test(policy.policyId) ||
!Number.isSafeInteger(policy.maxInspectionBytes) ||
policy.maxInspectionBytes < 1 ||
policy.maxInspectionBytes > hardMaxInspectionBytes
) {
throw new TypeError("File inspection policy is invalid.");
}
for (const rule of policy.acceptedSignatures) {
assertSignatureRule(rule, policy.maxInspectionBytes);
}
}
function assertSignatureRule(
rule: FileSignatureRule,
maxInspectionBytes: number,
): void {
if (
!MEDIA_TYPE.test(rule.mediaType) ||
rule.extensions.length === 0 ||
rule.extensions.some((extension) => !EXTENSION.test(extension)) ||
rule.patterns.length === 0
) {
throw new TypeError("File signature rule is invalid.");
}
for (const pattern of rule.patterns) {
assertBytePattern(pattern, maxInspectionBytes);
}
}
function assertBytePattern(
pattern: FileBytePattern,
maxInspectionBytes: number,
): void {
if (
!Number.isSafeInteger(pattern.offset) ||
pattern.offset < 0 ||
pattern.bytes.length === 0 ||
pattern.offset + pattern.bytes.length > maxInspectionBytes ||
pattern.bytes.some((byte) => !validByte(byte)) ||
(pattern.mask !== undefined &&
(pattern.mask.length !== pattern.bytes.length ||
pattern.mask.some((byte) => !validByte(byte))))
) {
throw new TypeError("File signature byte pattern is invalid.");
}
}
function validByte(value: number): boolean {
return Number.isInteger(value) && value >= 0 && value <= 0xff;
}
export function normalizedExtension(fileName: string): string | null {
const name = fileName.normalize("NFC");
const separator = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
const baseName = name.slice(separator + 1);
const index = baseName.lastIndexOf(".");
if (index <= 0 || index === baseName.length - 1) return null;
const extension = baseName.slice(index).toLowerCase();
return EXTENSION.test(extension) ? extension : null;
}
/**
* Picker accept metadata is only an early usability filter. Returning true
* here never establishes that the file content is safe.
*/
export function matchesSelectionHint(
candidate: Pick<FileCandidate, "displayName" | "reportedMediaType">,
accept: readonly FileAcceptRule[],
): boolean {
if (accept.length === 0) return true;
const extension = normalizedExtension(candidate.displayName);
const normalizedName = normalizedFileName(candidate.displayName);
const reported = candidate.reportedMediaType?.toLowerCase() ?? null;
return accept.some((rule) => {
const expected = rule.mediaType.toLowerCase();
const mediaMatches =
reported !== null &&
(expected === reported ||
(expected.endsWith("/*") &&
reported.startsWith(`${expected.slice(0, -1)}`)));
const extensionMatches =
rule.extensions.some(
(allowed) =>
normalizedName.endsWith(allowed.toLowerCase()) ||
(extension !== null &&
allowed.toLowerCase() === extension),
);
return mediaMatches || extensionMatches;
});
}
export function findMatchingSignature(
header: Uint8Array,
rules: readonly FileSignatureRule[],
): FileSignatureRule | null {
for (const rule of rules) {
if (rule.patterns.some((pattern) => matchesPattern(header, pattern))) {
return rule;
}
}
return null;
}
function matchesPattern(
header: Uint8Array,
pattern: FileBytePattern,
): boolean {
if (pattern.offset + pattern.bytes.length > header.byteLength) return false;
for (let index = 0; index < pattern.bytes.length; index += 1) {
const mask = pattern.mask?.[index] ?? 0xff;
const actual = header[pattern.offset + index];
const expected = pattern.bytes[index];
if (actual === undefined || expected === undefined) return false;
if ((actual & mask) !== (expected & mask)) return false;
}
return true;
}
export function signatureWasExpected(
fileName: string,
reportedMediaType: string | null,
rules: readonly FileSignatureRule[],
): boolean {
const extension = normalizedExtension(fileName);
const normalizedName = normalizedFileName(fileName);
const reported = reportedMediaType?.toLowerCase() ?? null;
return rules.some(
(rule) =>
(reported !== null &&
reported === rule.mediaType.toLowerCase()) ||
(extension !== null &&
rule.extensions.some(
(allowed) =>
normalizedName.endsWith(allowed.toLowerCase()) ||
allowed.toLowerCase() === extension,
)),
);
}
export function signatureMetadataMatches(
fileName: string,
reportedMediaType: string | null,
rule: FileSignatureRule,
): boolean {
const normalizedName = normalizedFileName(fileName);
const extension = normalizedExtension(fileName);
const reported = reportedMediaType?.toLowerCase() ?? null;
const extensionMatches =
extension === null ||
rule.extensions.some(
(allowed) =>
normalizedName.endsWith(allowed.toLowerCase()) ||
allowed.toLowerCase() === extension,
);
const mediaMatches =
reported === null || reported === rule.mediaType.toLowerCase();
return extensionMatches && mediaMatches;
}
export function sanitizeSuggestedFileName(
suggestedName: string,
policy: SuggestedFileNamePolicy,
): string {
const safeExtension = normalizeSafeExtension(policy.safeExtension);
const forbidden = new Set(
(policy.forbiddenExtensions ?? DEFAULT_FORBIDDEN_EXTENSIONS).map(
normalizeSafeExtension,
),
);
const configuredFallback = neutralizeForbiddenExtensions(
sanitizeBaseName(policy.fallbackBaseName ?? "download"),
forbidden,
);
const maxUtf8Bytes = policy.maxUtf8Bytes ?? 180;
if (
!Number.isSafeInteger(maxUtf8Bytes) ||
maxUtf8Bytes < utf8Length(`a${safeExtension}`)
) {
throw new TypeError("Suggested filename byte budget is invalid.");
}
const fallbackBase = fitFallbackBaseName(
configuredFallback,
safeExtension,
maxUtf8Bytes,
);
const lastPathSegment =
suggestedName
.normalize("NFC")
.split(/[\\/]/)
.at(-1) ?? "";
const cleaned = lastPathSegment
.split("")
.filter((character) => !isUnsafeFormatCharacter(character))
.join("")
.replace(FILE_SYSTEM_RESERVED, "_")
.trim()
.replace(/[ .]+$/g, "");
const lowerCleaned = cleaned.toLowerCase();
const existingExtension = lowerCleaned.endsWith(safeExtension)
? safeExtension
: normalizedExtension(cleaned);
const withoutFinalExtension =
existingExtension === null
? cleaned
: cleaned.slice(0, -existingExtension.length);
const neutralized = neutralizeForbiddenExtensions(
withoutFinalExtension,
forbidden,
);
let base = sanitizeBaseName(neutralized);
if (
base.length === 0 ||
base === "." ||
base === ".." ||
WINDOWS_RESERVED.test(base)
) {
base = fallbackBase.length > 0 ? fallbackBase : "download";
}
while (
base.length > 0 &&
utf8Length(`${base}${safeExtension}`) > maxUtf8Bytes
) {
base = Array.from(base).slice(0, -1).join("").trimEnd();
}
if (base.length === 0 || WINDOWS_RESERVED.test(base)) {
base = fallbackBase;
}
return `${base}${safeExtension}`;
}
function normalizeSafeExtension(extension: string): string {
const normalized = extension.normalize("NFC").toLowerCase();
if (!EXTENSION.test(normalized)) {
throw new TypeError("Safe filename extension is invalid.");
}
return normalized;
}
function sanitizeBaseName(value: string): string {
return value
.normalize("NFC")
.split("")
.filter((character) => !isUnsafeFormatCharacter(character))
.join("")
.replace(FILE_SYSTEM_RESERVED, "_")
.replace(/[\\/]/g, "_")
.trim()
.replace(/[ .]+$/g, "");
}
function neutralizeForbiddenExtensions(
value: string,
forbidden: ReadonlySet<string>,
): string {
return value.replace(/\.[a-z0-9+_-]+/gi, (extension) =>
forbidden.has(extension.toLowerCase())
? `_${extension.slice(1)}`
: extension,
);
}
function utf8Length(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
function fitFallbackBaseName(
configured: string,
extension: string,
maxUtf8Bytes: number,
): string {
let fallback =
configured.length > 0 && !WINDOWS_RESERVED.test(configured)
? configured
: "download";
while (
fallback.length > 0 &&
utf8Length(`${fallback}${extension}`) > maxUtf8Bytes
) {
fallback = Array.from(fallback).slice(0, -1).join("").trimEnd();
}
return fallback.length > 0 && !WINDOWS_RESERVED.test(fallback)
? fallback
: "a";
}
function normalizedFileName(value: string): string {
const normalized = value.normalize("NFC").toLowerCase();
const separator = Math.max(
normalized.lastIndexOf("/"),
normalized.lastIndexOf("\\"),
);
return normalized.slice(separator + 1);
}
function isUnsafeFormatCharacter(character: string): boolean {
const code = character.charCodeAt(0);
return (
code <= 0x1f ||
(code >= 0x7f && code <= 0x9f) ||
(code >= 0x202a && code <= 0x202e) ||
(code >= 0x2066 && code <= 0x2069)
);
}
+53
View File
@@ -0,0 +1,53 @@
export type {
BrowserManagedDownloadCapability,
BrowserManagedDownloadCapabilityReceipt,
BrowserManagedDownloadCapabilityResolver,
DownloadOutcome,
DownloadSource,
DownloadStrategy,
FileByteSource,
FilePolicyIntention,
FilePolicyKey,
FilePolicyReference,
FileSelectionLimitReduction,
FileSelectionOutcome,
FileVerificationReceipt,
LocalFileRef,
} from "../../application/ports/browser-file-storage/file.ts";
export {
BrowserFilePolicyRegistry,
browserFilePolicyReference,
type BrowserFilePolicyProfile,
type BrowserFilePolicyRegistryOptions,
type RegisteredDownloadPolicy,
type RegisteredPreviewPolicy,
type ResolvedDownloadPolicy,
type ResolvedInspectionPolicy,
type ResolvedPreviewPolicy,
} from "./browser-file-policy-registry.ts";
export type {
FileAcceptRule,
FileBytePattern,
FileSignatureRule,
RegisteredFileInspectionPolicy,
RegisteredFileSelectionPolicy,
} from "./file-policy.ts";
export {
createBrowserFileRuntime,
type BrowserFileRuntime,
type BrowserFileRuntimeOptions,
} from "./create-browser-file-runtime.ts";
export {
DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS,
type SystemOpenPicker,
type SystemOpenPickerOptions,
} from "./browser-file-picker.ts";
export {
DEFAULT_OBJECT_URL_RELEASE_GRACE_MS,
createAnchorDownloadHost,
type BrowserDownloadHost,
type SaveFileHandle,
type ShowSaveFilePicker,
} from "./download-delivery-adapter.ts";
export type { BrowserFileObserver } from "./file-observer.ts";
export type { ObjectUrlApi } from "./object-url-lease.ts";
@@ -0,0 +1,339 @@
import type {
FilePolicyReference,
FileVerificationReceipt,
LocalFileRef,
PreviewLease,
TransientPreviewPort,
} from "../../application/ports/browser-file-storage/file.ts";
import type { BrowserDataResult } from "../../application/ports/browser-file-storage/shared.ts";
import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts";
import {
abortedResult,
browserDataFailure,
browserDataSuccess,
mapBrowserDataException,
} from "../browser-file-storage/result.ts";
import type { NativeVerifiedFileResolver } from "./browser-file-vault.ts";
import {
byteBucket,
observeBrowserFile,
type BrowserFileObserver,
} from "./file-observer.ts";
import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts";
export type ObjectUrlApi = Readonly<{
createObjectURL(blob: Blob): string;
revokeObjectURL(url: string): void;
}>;
export type ObjectUrlLease = Readonly<{
url: string;
release(): void;
}>;
export type ObjectUrlLeaseLimits = Readonly<{
hardMaxActiveLeases: number;
hardMaxSingleLeaseBytes: number;
hardMaxAggregateLeaseBytes: number;
}>;
const DEFAULT_OBJECT_URL_LEASE_LIMITS: ObjectUrlLeaseLimits =
Object.freeze({
hardMaxActiveLeases: 16,
hardMaxSingleLeaseBytes: 64 * 1024 * 1024,
hardMaxAggregateLeaseBytes: 256 * 1024 * 1024,
});
/**
* The only low-level owner of object URL creation/revocation. Every lease is
* idempotent and dispose() is a final safety net for route/runtime teardown.
*/
export class ObjectUrlLeaseRegistry {
readonly #createObjectURL: ObjectUrlApi["createObjectURL"];
readonly #revokeObjectURL: ObjectUrlApi["revokeObjectURL"];
readonly #limits: ObjectUrlLeaseLimits;
readonly #active = new Map<
string,
Readonly<{ release(): void; byteLength: number }>
>();
#aggregateByteLength = 0;
constructor(
urlApi: ObjectUrlApi = URL,
limits: ObjectUrlLeaseLimits = DEFAULT_OBJECT_URL_LEASE_LIMITS,
) {
const createObjectURL = urlApi.createObjectURL;
const revokeObjectURL = urlApi.revokeObjectURL;
if (
typeof createObjectURL !== "function" ||
typeof revokeObjectURL !== "function"
) {
throw new TypeError("Object URL API is invalid.");
}
this.#createObjectURL = createObjectURL.bind(urlApi);
this.#revokeObjectURL = revokeObjectURL.bind(urlApi);
this.#limits = Object.freeze({
hardMaxActiveLeases: limits.hardMaxActiveLeases,
hardMaxSingleLeaseBytes: limits.hardMaxSingleLeaseBytes,
hardMaxAggregateLeaseBytes:
limits.hardMaxAggregateLeaseBytes,
});
if (
!isPositiveSafeInteger(this.#limits.hardMaxActiveLeases) ||
!isPositiveSafeInteger(
this.#limits.hardMaxSingleLeaseBytes,
) ||
!isPositiveSafeInteger(
this.#limits.hardMaxAggregateLeaseBytes,
) ||
this.#limits.hardMaxSingleLeaseBytes >
this.#limits.hardMaxAggregateLeaseBytes
) {
throw new TypeError("Object URL lease limits are invalid.");
}
}
create(blob: Blob): ObjectUrlLease {
if (
!isValidByteLength(blob.size) ||
blob.size > this.#limits.hardMaxSingleLeaseBytes ||
this.#active.size >= this.#limits.hardMaxActiveLeases ||
!Number.isSafeInteger(this.#aggregateByteLength + blob.size) ||
this.#aggregateByteLength + blob.size >
this.#limits.hardMaxAggregateLeaseBytes
) {
throw new DOMException(
"Object URL lease limit exceeded",
"FileTooLargeError",
);
}
const url = this.#createObjectURL(blob);
if (typeof url !== "string" || url.length === 0) {
if (typeof url === "string" && url.length > 0) {
try {
this.#revokeObjectURL(url);
} catch {
// The invalid lease is rejected regardless of cleanup support.
}
}
throw new DOMException(
"Object URL allocation failed",
"InvalidStateError",
);
}
if (this.#active.has(url)) {
throw new DOMException(
"Object URL allocation was not unique",
"InvalidStateError",
);
}
let released = false;
const release = (): void => {
if (released) return;
released = true;
if (this.#active.delete(url)) {
this.#aggregateByteLength -= blob.size;
}
try {
this.#revokeObjectURL(url);
} catch {
// Revocation is best effort and must remain idempotent.
}
};
this.#active.set(
url,
Object.freeze({ release, byteLength: blob.size }),
);
this.#aggregateByteLength += blob.size;
return Object.freeze({ url, release });
}
dispose(): void {
for (const lease of Array.from(this.#active.values())) {
lease.release();
}
}
get activeLeaseCount(): number {
return this.#active.size;
}
get aggregateLeaseByteLength(): number {
return this.#aggregateByteLength;
}
}
export type TransientPreviewOptions = Readonly<{
files: NativeVerifiedFileResolver;
policies: BrowserFilePolicyRegistry;
/**
* Runtime-owned absolute ceiling. Feature callers may request a lower
* maxPreviewBytes but can never raise this limit.
*/
hardMaxPreviewBytes: number;
leases?: ObjectUrlLeaseRegistry;
hardForbiddenMediaTypes?: ReadonlySet<string>;
observer?: BrowserFileObserver;
}>;
const DEFAULT_ACTIVE_CONTENT = new Set([
"application/pdf",
"application/xhtml+xml",
"application/xml",
"image/svg+xml",
"text/html",
"text/xml",
]);
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
export class BrowserTransientPreview implements TransientPreviewPort {
readonly #resolveVerifiedFile:
NativeVerifiedFileResolver["resolveVerifiedFile"];
readonly #resolvePreview:
BrowserFilePolicyRegistry["resolvePreview"];
readonly #createLease: ObjectUrlLeaseRegistry["create"];
readonly #disposeLeases: ObjectUrlLeaseRegistry["dispose"];
readonly #hardMaxPreviewBytes: number;
readonly #hardForbiddenMediaTypes: ReadonlySet<string>;
readonly #observer: BrowserFileObserver | undefined;
#disposed = false;
constructor(options: TransientPreviewOptions) {
this.#resolveVerifiedFile =
options.files.resolveVerifiedFile.bind(options.files);
this.#resolvePreview =
options.policies.resolvePreview.bind(options.policies);
const leases =
options.leases ?? new ObjectUrlLeaseRegistry();
this.#createLease = leases.create.bind(leases);
this.#disposeLeases = leases.dispose.bind(leases);
this.#hardMaxPreviewBytes = options.hardMaxPreviewBytes;
this.#hardForbiddenMediaTypes = new Set([
...Array.from(
DEFAULT_ACTIVE_CONTENT,
(mediaType) => mediaType.toLowerCase(),
),
...Array.from(
options.hardForbiddenMediaTypes ?? [],
(mediaType) => mediaType.toLowerCase(),
),
]);
this.#observer = options.observer;
if (
!isValidByteLength(this.#hardMaxPreviewBytes) ||
this.#hardMaxPreviewBytes === 0
) {
throw new TypeError("Preview hard byte limit is invalid.");
}
}
async create(input: {
ref: LocalFileRef;
verificationReceipt: FileVerificationReceipt;
policy: FilePolicyReference;
maxPreviewBytes?: number;
signal: AbortSignal;
}): Promise<BrowserDataResult<PreviewLease>> {
if (this.#disposed) {
return this.#observe(
browserDataFailure("UNAVAILABLE", "PREVIEW"),
);
}
const cancelled = abortedResult(input.signal, "PREVIEW");
if (cancelled) return this.#observe(cancelled);
const resolvedPolicy = this.#resolvePreview(
input.policy,
input.maxPreviewBytes,
);
if (!resolvedPolicy.ok) return this.#observe(resolvedPolicy);
const policy = resolvedPolicy.value;
if (policy.maxPreviewBytes > this.#hardMaxPreviewBytes) {
return this.#observe(
browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"),
);
}
try {
const resolved = await this.#resolveVerifiedFile({
ref: input.ref,
verificationReceipt: input.verificationReceipt,
verificationPolicyBindingId:
policy.verificationPolicyBindingId,
signal: input.signal,
});
if (!resolved.ok) return this.#observe(resolved);
if (this.#disposed) {
return this.#observe(
browserDataFailure("UNAVAILABLE", "PREVIEW"),
);
}
const mediaType = resolved.value.mediaType.trim().toLowerCase();
if (
!MEDIA_TYPE.test(mediaType) ||
!policy.allowedMediaTypes.has(mediaType) ||
this.#hardForbiddenMediaTypes.has(mediaType)
) {
return this.#observe(
browserDataFailure("POLICY_REJECTED", "PREVIEW"),
);
}
if (resolved.value.file.size > policy.maxPreviewBytes) {
return this.#observe(
browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"),
);
}
// A typed slice prevents the untrusted File.type from controlling how
// the object URL is interpreted.
const typedBlob = resolved.value.file.slice(
0,
resolved.value.file.size,
mediaType,
);
const lease = this.#createLease(typedBlob);
const preview: PreviewLease = Object.freeze({
url: lease.url,
mediaType,
release: lease.release,
});
observeBrowserFile(this.#observer, {
operation: "PREVIEW",
outcome: "SUCCESS",
byteBucket: byteBucket(resolved.value.file.size),
});
return browserDataSuccess(preview);
} catch (error) {
if (
error instanceof DOMException &&
error.name === "FileTooLargeError"
) {
return this.#observe(
browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"),
);
}
return this.#observe(mapBrowserDataException(error, "PREVIEW"));
}
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#disposeLeases();
}
#observe<Value>(
result: BrowserDataResult<Value>,
): BrowserDataResult<Value> {
if (!result.ok) {
observeBrowserFile(this.#observer, {
operation: "PREVIEW",
outcome: "FAILED",
failureCode: result.error.code,
});
}
return result;
}
}
function isPositiveSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
export {
createBrowserRpcRuntime,
type BrowserRpcObservation,
type BrowserRpcObservationOutcome,
type BrowserRpcObservationSink,
type BrowserRpcRuntime,
type BrowserRpcRuntimeDependencies,
} from "./browser-rpc-runtime.ts";
export {
defineBrowserRpcTransport,
type BrowserRpcStreamFrame,
type BrowserRpcTransport,
type BrowserRpcTransportCall,
type BrowserRpcTransportFailure,
type BrowserRpcUnaryTransportResult,
} from "./transport.ts";
export { createUnavailableBrowserRpcTransport } from "./unavailable-browser-rpc-transport.ts";
+89
View File
@@ -0,0 +1,89 @@
import type {
BrowserRpcKind,
BrowserRpcOperationV3,
BrowserRpcProtocol,
BrowserRpcProviderProfile,
BrowserRpcRuntimeBindingIdentity,
BrowserRpcTransportFailureCode,
} from "../../contracts/browser-rpc.ts";
export type BrowserRpcTransportFailure = Readonly<{
code: BrowserRpcTransportFailureCode;
retryAfterMs?: number;
}>;
export type BrowserRpcTransportCall = Readonly<{
operation: BrowserRpcOperationV3;
profile: BrowserRpcProviderProfile;
request: unknown;
encodedRequestBytes: number;
attempt: number;
timeoutMs: number;
signal: AbortSignal;
idempotencyKey?: string;
}>;
export type BrowserRpcUnaryTransportResult =
| Readonly<{
ok: true;
message: unknown;
encodedBytes: number;
}>
| Readonly<{
ok: false;
failure: BrowserRpcTransportFailure;
}>;
export type BrowserRpcStreamFrame =
| Readonly<{
kind: "MESSAGE";
message: unknown;
encodedBytes: number;
}>
| Readonly<{
kind: "TERMINAL";
ok: true;
}>
| Readonly<{
kind: "TERMINAL";
ok: false;
failure: BrowserRpcTransportFailure;
}>;
export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity &
Readonly<{
invokeUnary?(
call: BrowserRpcTransportCall,
): Promise<BrowserRpcUnaryTransportResult>;
openServerStream?(
call: BrowserRpcTransportCall,
): AsyncIterable<BrowserRpcStreamFrame>;
}>;
export function defineBrowserRpcTransport(
transport: BrowserRpcTransport,
): BrowserRpcTransport {
if (
!transport.runtimeProfileId ||
!transport.providerId ||
!isProtocol(transport.protocol) ||
!isRpcKind(transport.rpcKind) ||
(transport.rpcKind === "UNARY" &&
(typeof transport.invokeUnary !== "function" ||
transport.openServerStream !== undefined)) ||
(transport.rpcKind === "SERVER_STREAM" &&
(typeof transport.openServerStream !== "function" ||
transport.invokeUnary !== undefined))
) {
throw new TypeError("Browser RPC transport is invalid.");
}
return Object.freeze({ ...transport });
}
function isProtocol(value: string): value is BrowserRpcProtocol {
return value === "CONNECT_HTTP" || value === "GRPC_WEB";
}
function isRpcKind(value: string): value is BrowserRpcKind {
return value === "UNARY" || value === "SERVER_STREAM";
}
@@ -0,0 +1,42 @@
import type {
BrowserRpcKind,
BrowserRpcProtocol,
} from "../../contracts/browser-rpc.ts";
import {
defineBrowserRpcTransport,
type BrowserRpcTransport,
} from "./transport.ts";
/**
* Explicit fail-closed adapter for an optional Browser RPC profile that has
* not been connected to a generated client/provider. It never performs
* network I/O and cannot silently fall back to REST.
*/
export function createUnavailableBrowserRpcTransport(input: Readonly<{
runtimeProfileId: string;
providerId: string;
protocol: BrowserRpcProtocol;
rpcKind: BrowserRpcKind;
}>): BrowserRpcTransport {
if (input.rpcKind === "UNARY") {
return defineBrowserRpcTransport({
...input,
async invokeUnary() {
return Object.freeze({
ok: false,
failure: Object.freeze({ code: "UNAVAILABLE" }),
});
},
});
}
return defineBrowserRpcTransport({
...input,
async *openServerStream() {
yield Object.freeze({
kind: "TERMINAL",
ok: false,
failure: Object.freeze({ code: "UNAVAILABLE" }),
});
},
});
}
@@ -0,0 +1,167 @@
# Image CDN composition
This adapter accepts no source URL or transform query from a feature. Product
composition owns the origin registry and named presets. A backend gateway may
use `runtime.assets`; presentation receives only the narrow
`runtime.presentation` facade plus registry-issued asset and preset
references.
```ts
import {
ImageCdnPolicyRegistry,
createBrowserImageProbe,
createImageCdnRuntime,
createP256ImageCapabilityVerifier,
imageCdnPresetReference,
} from "./index.ts";
const cardImage = imageCdnPresetReference(
"product-card",
"render-product-card-image",
);
const policies = new ImageCdnPolicyRegistry({
applicationOrigin: "https://app.example.com",
origins: [{
originKey: "product-images",
origin: "https://images.example.com",
assetPathPrefix: "/v1/assets/",
minimumPublicMaxAgeSeconds: 31_536_000,
}],
presets: [{
reference: cardImage,
bindingId: "product-card-v1",
width: 640,
height: 360,
fit: "cover",
dprs: [1, 2],
responsiveWidths: [320, 640],
quality: 80,
formats: ["avif", "webp", "jpeg"],
sizes: "(max-width: 640px) 100vw, 640px",
loading: "eager",
decoding: "async",
fetchPriority: "high",
referrerPolicy: "no-referrer",
probeMode: "PRIMARY_REQUIRED",
allowUpscale: false,
maxTransformedPixels: 1_048_576,
maxDecodedBytes: 4_194_304,
maxEncodedBytes: 524_288,
}],
hardLimits: {
maxIntrinsicWidth: 4_096,
maxIntrinsicHeight: 4_096,
maxSourcePixels: 16_777_216,
maxCssDimension: 2_048,
maxDpr: 2,
maxQuality: 90,
maxCandidateCount: 8,
maxTransformedPixels: 1_048_576,
maxDecodedBytes: 4_194_304,
maxEncodedBytes: 524_288,
maxUrlLength: 2_048,
maxCapabilityLifetimeMs: 3_600_000,
maxClockSkewMs: 60_000,
minCapabilityRemainingMs: 30_000,
maxPresetBindingsPerCapability: 8,
maxConcurrentCapabilityVerifications: 8,
allowedSourceMediaTypes: [
"image/avif",
"image/jpeg",
"image/png",
"image/webp",
],
formatQualityCeilings: {
avif: 80,
jpeg: 85,
png: 90,
webp: 85,
},
},
capability: {
issuer: "image-bff",
acceptedKeyIds: [
"image-signing-2026-02",
"image-signing-2026-01",
],
},
});
const runtime = createImageCdnRuntime({
policies,
subtle: crypto.subtle,
capabilityVerifier: createP256ImageCapabilityVerifier({
subtle: crypto.subtle,
publicKeys: [{
keyId: "image-signing-2026-02",
key: currentImageCapabilityPublicKey,
}, {
keyId: "image-signing-2026-01",
key: previousImageCapabilityPublicKey,
}],
}),
capabilityVerificationTimeoutMs: 5_000,
probe: createBrowserImageProbe(),
observer: safeBrowserDataObserver,
});
// `payload` is a strictly decoded BackendIssuedImageAsset from the BFF.
const accepted = await runtime.assets.acceptBackendIssued(payload, {
signal,
});
if (!accepted.ok) return accepted;
// Expose only this closure to the feature/presentation composition.
const resolveCardImage = (signal: AbortSignal) =>
runtime.presentation.resolve({
asset: accepted.value,
preset: cardImage,
signal,
});
// Application-scope teardown, logout, account/tenant partition change, or
// replacement by a newly composed runtime. Never call this per render.
const closeImageRuntime = (): void => runtime.close();
```
For a public immutable asset, the trusted gateway calls
`acceptPublicImmutable` with only an allowlisted `originKey`, opaque `assetId`
and `revision`, raster metadata and intrinsic dimensions. `applicationOrigin`
must be the deployment's canonical HTTPS origin, without a trailing slash,
and every CDN origin must differ from it. This is required because an
`anonymous` image request omits credentials only when it is cross-origin.
Private descriptors must be backend-signed, remain above the configured
minimum TTL at every resolve, and use an eager, non-low-priority
`PRIMARY_REQUIRED` preset. Digest and signature verification share one
composition-owned deadline and race the caller's abort signal. The probe sends
no credentials, rejects any final URL other than the exact signed URL, and
requires the private response to declare the flag-only directive
`Cache-Control: no-store`.
Before native decode, the adapter parses the bounded PNG, JPEG, WebP or AVIF
container, rejects animation and enforces both pixel and decoded-byte budgets.
Its adapter-owned timeout covers response headers, streamed body consumption
and decode; abort paths cancel the reader and close even a late ImageBitmap.
The client never purges a CDN: public URLs roll forward by revision, while
private delivery relies on backend capability revocation or expiry.
Composition-supplied hard limits may only tighten
`IMAGE_CDN_IMPLEMENTATION_CEILINGS`; configuration cannot raise intrinsic,
source/output pixel, decoded/encoded byte, candidate, URL or capability
lifetime ceilings owned by the adapter. Private verification additionally
reserves one of the bounded `maxConcurrentCapabilityVerifications` slots and
always releases it after success, failure, abort or close.
`acceptedKeyIds` is a bounded, unique overlap set, not the active signing-key
selector. The verifier registry must cover every accepted ID. Rotate by first
deploying the new public key and an old/new overlap set, then switch the
backend signer. Retain the old key for client rollout plus at least
`maxCapabilityLifetimeMs + maxClockSkewMs`; remove it only after old clients
and capabilities are exhausted. A compromised key instead requires backend
revocation, `runtime.close()`, recomposition and a forced client rollout.
`close()` is terminal and idempotent. It aborts in-flight private verification
and probing and replaces the runtime's WeakMap capability registry, immediately
revoking every issued reference without retaining them strongly. Every later
accept or resolve returns closed `UNAVAILABLE`; resuming requires a newly
composed runtime.
@@ -0,0 +1,609 @@
import type {
ImageProbeRequest,
ImageResourceProbePort,
} from "../../../application/ports/browser-transfer/image-cdn.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import { parseStaticImageHeaderMetadata } from "./image-header-metadata.ts";
export type DecodedImageFacade = Readonly<{
width: number;
height: number;
close(): void;
}>;
export type ImageProbeScheduler = Readonly<{
setTimeout(callback: () => void, milliseconds: number): unknown;
clearTimeout(handle: unknown): void;
}>;
export type BrowserImageProbeDependencies = Readonly<{
fetcher?: typeof fetch;
createBitmap?: (
image: Blob,
) => Promise<DecodedImageFacade>;
/** Covers fetch headers, streamed body consumption and native decode. */
timeoutMs?: number;
scheduler?: ImageProbeScheduler;
}>;
const DEFAULT_TIMEOUT_MS = 5_000;
const MAXIMUM_TIMEOUT_MS = 60_000;
/**
* Performs one bounded real response/decode probe. It is intentionally a
* separate seam because probing every srcset candidate would defeat responsive
* image loading and consume the entire transfer budget up front.
*/
export function createBrowserImageProbe(
dependencies: BrowserImageProbeDependencies = {},
): ImageResourceProbePort {
const fetcher = (dependencies.fetcher ?? fetch).bind(globalThis);
const createBitmap =
dependencies.createBitmap ??
(typeof createImageBitmap === "function"
? async (image: Blob) => createImageBitmap(image)
: undefined);
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const scheduler = snapshotScheduler(
dependencies.scheduler ?? defaultScheduler(),
);
if (
!positiveSafeInteger(timeoutMs) ||
timeoutMs > MAXIMUM_TIMEOUT_MS
) {
throw new TypeError("Image probe timeout is invalid.");
}
return Object.freeze({
async probe(request: ImageProbeRequest) {
if (request.signal.aborted) {
return browserDataFailure("ABORTED", "IMAGE_RESOLVE");
}
let url: URL;
try {
url = new URL(request.absoluteUrl);
} catch {
return browserDataFailure("INVALID_INPUT", "IMAGE_RESOLVE");
}
if (
url.protocol !== "https:" ||
url.username !== "" ||
url.password !== "" ||
url.hash !== "" ||
!positiveSafeInteger(request.expectedWidth) ||
!positiveSafeInteger(request.expectedHeight) ||
!positiveSafeInteger(request.maxEncodedBytes) ||
!positiveSafeInteger(request.maxDecodedPixels) ||
!positiveSafeInteger(request.maxDecodedBytes) ||
!withinDecodeBudget(
request.expectedWidth,
request.expectedHeight,
request.maxDecodedPixels,
request.maxDecodedBytes,
) ||
!isRasterMediaType(request.expectedMediaType) ||
request.referrerPolicy !== "no-referrer" &&
request.referrerPolicy !==
"strict-origin-when-cross-origin"
) {
return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE");
}
if (!createBitmap) {
return browserDataFailure("UNSUPPORTED", "IMAGE_RESOLVE");
}
const scope = createProbeAbortScope(
request.signal,
timeoutMs,
scheduler,
);
let response: Response | undefined;
try {
try {
const fetchTask = Promise.resolve(
fetcher(url.href, {
method: "GET",
cache: "no-store",
credentials: "omit",
mode: "cors",
redirect: "error",
referrerPolicy: request.referrerPolicy,
signal: scope.signal,
}),
);
response = await awaitWithAbort(
fetchTask,
scope.signal,
(lateResponse) => {
cancelResponseBody(lateResponse);
},
);
} catch {
return signalFailure(request.signal, scope);
}
if (
response.status !== 200 ||
!response.ok ||
response.redirected ||
["error", "opaque", "opaqueredirect"].includes(
response.type,
) ||
response.url !== url.href ||
!validResponseHeaders(response, request)
) {
cancelResponseBody(response);
return browserDataFailure(
"POLICY_REJECTED",
"IMAGE_RESOLVE",
);
}
let bytes: Uint8Array;
try {
bytes = await readBoundedBody(
response,
request.maxEncodedBytes,
scope.signal,
);
} catch (error) {
if (request.signal.aborted || scope.timedOut()) {
return signalFailure(request.signal, scope);
}
return error instanceof EncodedBodyLimitError
? browserDataFailure(
"LIMIT_EXCEEDED",
"IMAGE_RESOLVE",
)
: browserDataFailure(
"UNAVAILABLE",
"IMAGE_RESOLVE",
{
retryable: true,
recovery: "RETRY",
},
);
}
const declaredLength = response.headers.get(
"content-length",
);
if (
declaredLength !== null &&
Number(declaredLength) !== bytes.byteLength
) {
return browserDataFailure(
"INTEGRITY_FAILED",
"IMAGE_RESOLVE",
);
}
const metadata = parseStaticImageHeaderMetadata(
bytes,
request.expectedMediaType,
);
if (!metadata) {
return browserDataFailure(
"INTEGRITY_FAILED",
"IMAGE_RESOLVE",
);
}
if (
!withinDecodeBudget(
metadata.width,
metadata.height,
request.maxDecodedPixels,
request.maxDecodedBytes,
)
) {
return browserDataFailure(
"LIMIT_EXCEEDED",
"IMAGE_RESOLVE",
);
}
if (
metadata.width !== request.expectedWidth ||
metadata.height !== request.expectedHeight
) {
return browserDataFailure(
"INTEGRITY_FAILED",
"IMAGE_RESOLVE",
);
}
let bitmap: DecodedImageFacade | undefined;
try {
const blobBytes = new Uint8Array(bytes.byteLength);
blobBytes.set(bytes);
const decodeTask = createBitmap(
new Blob([blobBytes.buffer], {
type: request.expectedMediaType,
}),
);
bitmap = await awaitWithAbort(
decodeTask,
scope.signal,
closeBitmap,
);
if (
!positiveSafeInteger(bitmap.width) ||
!positiveSafeInteger(bitmap.height) ||
bitmap.width !== metadata.width ||
bitmap.height !== metadata.height ||
!withinDecodeBudget(
bitmap.width,
bitmap.height,
request.maxDecodedPixels,
request.maxDecodedBytes,
)
) {
return browserDataFailure(
"INTEGRITY_FAILED",
"IMAGE_RESOLVE",
);
}
return browserDataSuccess(
Object.freeze({
absoluteUrl: url.href,
mediaType: request.expectedMediaType,
encodedBytes: bytes.byteLength,
decodedWidth: bitmap.width,
decodedHeight: bitmap.height,
}),
);
} catch {
return request.signal.aborted || scope.timedOut()
? signalFailure(request.signal, scope)
: browserDataFailure(
"INTEGRITY_FAILED",
"IMAGE_RESOLVE",
);
} finally {
if (bitmap) closeBitmap(bitmap);
}
} finally {
scope.release();
}
},
});
}
function validResponseHeaders(
response: Response,
request: ImageProbeRequest,
): boolean {
const rawContentType =
response.headers.get("content-type")?.trim().toLowerCase();
if (
rawContentType !== request.expectedMediaType ||
response.headers.has("set-cookie") ||
response.headers.has("set-cookie2")
) {
return false;
}
const rawLength = response.headers.get("content-length");
const contentEncoding = response.headers.get("content-encoding");
if (
(contentEncoding !== null &&
contentEncoding.trim().toLowerCase() !== "identity") ||
rawLength !== null &&
(!/^(?:0|[1-9]\d*)$/u.test(rawLength) ||
Number(rawLength) > request.maxEncodedBytes)
) {
return false;
}
const vary = response.headers.get("vary");
if (
vary &&
vary
.split(",")
.map((name) => name.trim().toLowerCase())
.some((name) =>
["*", "authorization", "cookie"].includes(name),
)
) {
return false;
}
const directives = parseCacheControl(
response.headers.get("cache-control"),
);
if (!directives) return false;
if (request.delivery === "PRIVATE_SIGNED") {
return (
directives.get("no-store") === true &&
!directives.has("public")
);
}
const maxAge = directives.get("max-age");
const sharedMaxAge = directives.get("s-maxage");
return (
directives.get("public") === true &&
directives.get("immutable") === true &&
!directives.has("private") &&
!directives.has("no-cache") &&
!directives.has("no-store") &&
!directives.has("must-revalidate") &&
!directives.has("proxy-revalidate") &&
typeof maxAge === "string" &&
/^(?:0|[1-9]\d*)$/u.test(maxAge) &&
Number(maxAge) >= request.minimumPublicMaxAgeSeconds &&
(sharedMaxAge === undefined ||
(typeof sharedMaxAge === "string" &&
/^(?:0|[1-9]\d*)$/u.test(sharedMaxAge) &&
Number(sharedMaxAge) >=
request.minimumPublicMaxAgeSeconds))
);
}
function parseCacheControl(
value: string | null,
): ReadonlyMap<string, string | true> | null {
const flagDirectives = new Set([
"immutable",
"must-revalidate",
"no-store",
"private",
"proxy-revalidate",
"public",
]);
const directives = new Map<string, string | true>();
for (const part of value?.split(",") ?? []) {
const trimmedPart = part.trim();
const separator = trimmedPart.indexOf("=");
const name = (
separator < 0
? trimmedPart
: trimmedPart.slice(0, separator)
)
.trim()
.toLowerCase();
if (!name) continue;
if (directives.has(name)) return null;
if (separator < 0) {
directives.set(name, true);
continue;
}
if (flagDirectives.has(name)) return null;
const rawValue = trimmedPart.slice(separator + 1).trim();
if (rawValue === "") return null;
directives.set(name, rawValue.replace(/^"|"$/gu, ""));
}
return directives;
}
class EncodedBodyLimitError extends Error {}
async function readBoundedBody(
response: Response,
maximumBytes: number,
signal: AbortSignal,
): Promise<Uint8Array> {
if (!response.body) {
throw new TypeError("Image response body is unavailable.");
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
if (signal.aborted) throw abortException();
const next = await awaitWithAbort(
reader.read(),
signal,
() => undefined,
);
if (next.done) break;
if (!(next.value instanceof Uint8Array)) {
throw new TypeError("Image response chunk is invalid.");
}
total += next.value.byteLength;
if (total > maximumBytes) {
throw new EncodedBodyLimitError();
}
chunks.push(Uint8Array.from(next.value));
}
} catch (error) {
cancelReader(reader);
throw error;
} finally {
try {
reader.releaseLock();
} catch {
// The closed result remains authoritative if a host stream is broken.
}
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
type ProbeAbortScope = Readonly<{
signal: AbortSignal;
timedOut(): boolean;
release(): void;
}>;
function createProbeAbortScope(
externalSignal: AbortSignal,
timeoutMs: number,
scheduler: ImageProbeScheduler,
): ProbeAbortScope {
const controller = new AbortController();
let timeoutReached = false;
let released = false;
const onExternalAbort = () => {
controller.abort(externalSignal.reason);
};
externalSignal.addEventListener("abort", onExternalAbort, {
once: true,
});
if (externalSignal.aborted) onExternalAbort();
const timeoutHandle = scheduler.setTimeout(() => {
if (released) return;
timeoutReached = true;
controller.abort(abortException());
}, timeoutMs);
return Object.freeze({
signal: controller.signal,
timedOut: () => timeoutReached,
release() {
if (released) return;
released = true;
try {
scheduler.clearTimeout(timeoutHandle);
} catch {
// A broken optional scheduler cannot change a terminal probe result.
}
externalSignal.removeEventListener("abort", onExternalAbort);
},
});
}
function awaitWithAbort<Value>(
task: Promise<Value>,
signal: AbortSignal,
onLateValue: (value: Value) => void,
): Promise<Value> {
return new Promise<Value>((resolve, reject) => {
let settled = false;
const onAbort = () => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortException());
};
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
void task.then(
(value) => {
if (settled) {
onLateValue(value);
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
function signalFailure(
externalSignal: AbortSignal,
scope: ProbeAbortScope,
) {
return externalSignal.aborted
? browserDataFailure("ABORTED", "IMAGE_RESOLVE")
: scope.timedOut()
? browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
retryable: true,
recovery: "RETRY",
})
: browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
retryable: true,
recovery: "RETRY",
});
}
function cancelResponseBody(response: Response): void {
try {
const cancellation = response.body?.cancel();
void cancellation?.catch(() => undefined);
} catch {
// Best-effort release cannot change the closed probe result.
}
}
function cancelReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
): void {
try {
void reader.cancel().catch(() => undefined);
} catch {
// Best-effort release cannot change the closed probe result.
}
}
function closeBitmap(bitmap: DecodedImageFacade): void {
try {
bitmap.close();
} catch {
// Decode correctness is independent from best-effort native release.
}
}
function abortException(): DOMException {
return new DOMException("Image probe was aborted.", "AbortError");
}
function withinDecodeBudget(
width: number,
height: number,
maximumPixels: number,
maximumBytes: number,
): boolean {
const pixels = width * height;
const decodedBytes = pixels * 4;
return (
Number.isSafeInteger(pixels) &&
Number.isSafeInteger(decodedBytes) &&
pixels <= maximumPixels &&
decodedBytes <= maximumBytes
);
}
function snapshotScheduler(
scheduler: ImageProbeScheduler,
): ImageProbeScheduler {
if (
!scheduler ||
typeof scheduler.setTimeout !== "function" ||
typeof scheduler.clearTimeout !== "function"
) {
throw new TypeError("Image probe scheduler is invalid.");
}
return Object.freeze({
setTimeout: scheduler.setTimeout.bind(scheduler),
clearTimeout: scheduler.clearTimeout.bind(scheduler),
});
}
function defaultScheduler(): ImageProbeScheduler {
return Object.freeze({
setTimeout(callback: () => void, milliseconds: number) {
return globalThis.setTimeout(callback, milliseconds);
},
clearTimeout(handle: unknown) {
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
);
},
});
}
function isRasterMediaType(
value: string,
): value is ImageProbeRequest["expectedMediaType"] {
return [
"image/avif",
"image/jpeg",
"image/png",
"image/webp",
].includes(value);
}
function positiveSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
@@ -0,0 +1,753 @@
import type {
ImageFit,
ImageOutputFormat,
ImagePresetReference,
ImageRasterMediaType,
} from "../../../application/ports/browser-transfer/image-cdn.ts";
export type ImageCdnOriginPolicy = Readonly<{
originKey: string;
origin: string;
assetPathPrefix: string;
minimumPublicMaxAgeSeconds: number;
}>;
export type ImageCdnPresetPolicy = Readonly<{
reference: ImagePresetReference;
bindingId: string;
width: number;
height: number;
fit: ImageFit;
dprs: readonly number[];
responsiveWidths: readonly number[];
quality: number;
formats: readonly ImageOutputFormat[];
sizes: string;
loading: "eager" | "lazy";
decoding: "async" | "sync";
fetchPriority: "high" | "low" | "auto";
referrerPolicy: "no-referrer" | "strict-origin-when-cross-origin";
probeMode: "NONE" | "PRIMARY_REQUIRED";
allowUpscale: boolean;
maxTransformedPixels: number;
maxDecodedBytes: number;
maxEncodedBytes: number;
}>;
export type ImageCdnHardLimits = Readonly<{
maxIntrinsicWidth: number;
maxIntrinsicHeight: number;
maxSourcePixels: number;
maxCssDimension: number;
maxDpr: number;
maxQuality: number;
maxCandidateCount: number;
maxTransformedPixels: number;
maxDecodedBytes: number;
maxEncodedBytes: number;
maxUrlLength: number;
maxCapabilityLifetimeMs: number;
maxClockSkewMs: number;
minCapabilityRemainingMs: number;
maxPresetBindingsPerCapability: number;
maxConcurrentCapabilityVerifications: number;
allowedSourceMediaTypes: readonly ImageRasterMediaType[];
formatQualityCeilings: Readonly<
Partial<Record<ImageOutputFormat, number>>
>;
}>;
export type ImageCdnCapabilityPolicy = Readonly<{
issuer: string;
acceptedKeyIds: readonly string[];
}>;
export type ImageCdnPolicyRegistryOptions = Readonly<{
applicationOrigin: string;
origins: readonly ImageCdnOriginPolicy[];
presets: readonly ImageCdnPresetPolicy[];
hardLimits: ImageCdnHardLimits;
capability: ImageCdnCapabilityPolicy;
}>;
export type ResolvedImageCdnOrigin = Readonly<{
originKey: string;
origin: string;
assetPathPrefix: string;
minimumPublicMaxAgeSeconds: number;
}>;
export type ImageCandidateGeometry = Readonly<{
cssWidth: number;
cssHeight: number;
dpr: number;
pixelWidth: number;
pixelHeight: number;
pixels: number;
decodedBytes: number;
}>;
export type ResolvedImageCdnPreset = Omit<
ImageCdnPresetPolicy,
"dprs" | "responsiveWidths" | "formats"
> &
Readonly<{
dprs: readonly number[];
responsiveWidths: readonly number[];
formats: readonly ImageOutputFormat[];
candidates: readonly ImageCandidateGeometry[];
}>;
const POLICY_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
const PATH_PREFIX = /^\/[A-Za-z0-9/_-]{1,200}\/$/u;
const SAFE_SIZES = /^[^<>"']{1,512}$/u;
const IMAGE_FORMATS = Object.freeze([
"avif",
"jpeg",
"png",
"webp",
] as const);
const IMAGE_MEDIA_TYPES = Object.freeze([
"image/avif",
"image/jpeg",
"image/png",
"image/webp",
] as const);
const IMAGE_FITS = Object.freeze([
"contain",
"cover",
"fill",
"inside",
"outside",
] as const);
const ISSUED_PRESET_REFERENCES = new WeakSet<object>();
export const IMAGE_CDN_IMPLEMENTATION_CEILINGS = Object.freeze({
maxIntrinsicWidth: 16_384,
maxIntrinsicHeight: 16_384,
maxSourcePixels: 67_108_864,
maxCssDimension: 8_192,
maxDpr: 4,
maxQuality: 100,
maxCandidateCount: 32,
maxTransformedPixels: 16_777_216,
maxDecodedBytes: 67_108_864,
maxEncodedBytes: 16_777_216,
maxUrlLength: 8_192,
maxCapabilityLifetimeMs: 86_400_000,
maxClockSkewMs: 300_000,
maxMinimumCapabilityRemainingMs: 3_600_000,
maxPresetBindingsPerCapability: 32,
maxConcurrentCapabilityVerifications: 32,
maxAcceptedKeyIds: 8,
} as const);
const REGISTRY_KEYS = Object.freeze([
"applicationOrigin",
"origins",
"presets",
"hardLimits",
"capability",
] as const);
const ORIGIN_KEYS = Object.freeze([
"originKey",
"origin",
"assetPathPrefix",
"minimumPublicMaxAgeSeconds",
] as const);
const HARD_LIMIT_KEYS = Object.freeze([
"maxIntrinsicWidth",
"maxIntrinsicHeight",
"maxSourcePixels",
"maxCssDimension",
"maxDpr",
"maxQuality",
"maxCandidateCount",
"maxTransformedPixels",
"maxDecodedBytes",
"maxEncodedBytes",
"maxUrlLength",
"maxCapabilityLifetimeMs",
"maxClockSkewMs",
"minCapabilityRemainingMs",
"maxPresetBindingsPerCapability",
"maxConcurrentCapabilityVerifications",
"allowedSourceMediaTypes",
"formatQualityCeilings",
] as const);
const CAPABILITY_KEYS = Object.freeze([
"issuer",
"acceptedKeyIds",
] as const);
const PRESET_KEYS = Object.freeze([
"reference",
"bindingId",
"width",
"height",
"fit",
"dprs",
"responsiveWidths",
"quality",
"formats",
"sizes",
"loading",
"decoding",
"fetchPriority",
"referrerPolicy",
"probeMode",
"allowUpscale",
"maxTransformedPixels",
"maxDecodedBytes",
"maxEncodedBytes",
] as const);
export const IMAGE_FORMAT_MEDIA_TYPE: Readonly<
Record<ImageOutputFormat, ImageRasterMediaType>
> = Object.freeze({
avif: "image/avif",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
});
/**
* The returned identity must be passed through a narrow feature facade.
* Constructing another reference with the same strings does not grant access.
*/
export function imageCdnPresetReference(
presetKey: string,
intention: string,
): ImagePresetReference {
if (!POLICY_TOKEN.test(presetKey) || !POLICY_TOKEN.test(intention)) {
throw new TypeError("Image CDN preset reference is invalid.");
}
const reference = Object.freeze({
presetKey,
intention,
}) as ImagePresetReference;
ISSUED_PRESET_REFERENCES.add(reference);
return reference;
}
/**
* Immutable composition-time policy registry. Every caller-owned collection
* is copied and all methods return snapshots rather than mutable registry
* state.
*/
export class ImageCdnPolicyRegistry {
readonly #origins: ReadonlyMap<string, ResolvedImageCdnOrigin>;
readonly #presets:
ReadonlyMap<ImagePresetReference, ResolvedImageCdnPreset>;
readonly #presetBindingIds: ReadonlySet<string>;
readonly #hardLimits: ImageCdnHardLimits;
readonly #capability: ImageCdnCapabilityPolicy;
constructor(options: ImageCdnPolicyRegistryOptions) {
if (!hasExactOwnKeys(options, REGISTRY_KEYS)) {
throw new TypeError("Image CDN policy registry is invalid.");
}
const applicationOrigin = snapshotApplicationOrigin(
options.applicationOrigin,
);
this.#hardLimits = snapshotHardLimits(options.hardLimits);
this.#capability = snapshotCapabilityPolicy(options.capability);
if (
!Array.isArray(options.origins) ||
options.origins.length < 1 ||
options.origins.length > 32 ||
!Array.isArray(options.presets) ||
options.presets.length < 1 ||
options.presets.length > 128
) {
throw new TypeError("Image CDN policy registry is invalid.");
}
const origins = new Map<string, ResolvedImageCdnOrigin>();
const absoluteOrigins = new Set<string>();
for (const input of options.origins) {
const origin = snapshotOrigin(input);
if (
origins.has(origin.originKey) ||
absoluteOrigins.has(origin.origin) ||
origin.origin === applicationOrigin
) {
throw new TypeError(
"Image CDN origin policy must be unique and cross-origin.",
);
}
origins.set(origin.originKey, origin);
absoluteOrigins.add(origin.origin);
}
const presets =
new Map<ImagePresetReference, ResolvedImageCdnPreset>();
const semanticReferences = new Set<string>();
const bindingIds = new Set<string>();
for (const input of options.presets) {
const preset = snapshotPreset(input, this.#hardLimits);
const semanticReference =
`${preset.reference.presetKey}:${preset.reference.intention}`;
if (
semanticReferences.has(semanticReference) ||
bindingIds.has(preset.bindingId)
) {
throw new TypeError("Image CDN preset policy is duplicated.");
}
semanticReferences.add(semanticReference);
bindingIds.add(preset.bindingId);
presets.set(preset.reference, preset);
}
this.#origins = origins;
this.#presets = presets;
this.#presetBindingIds = bindingIds;
}
resolveOrigin(originKey: string): ResolvedImageCdnOrigin | null {
return this.#origins.get(originKey) ?? null;
}
resolvePreset(
reference: ImagePresetReference,
): ResolvedImageCdnPreset | null {
if (
!reference ||
typeof reference !== "object" ||
!ISSUED_PRESET_REFERENCES.has(reference)
) {
return null;
}
return this.#presets.get(reference) ?? null;
}
hasPresetBinding(bindingId: string): boolean {
return this.#presetBindingIds.has(bindingId);
}
hardLimits(): ImageCdnHardLimits {
return this.#hardLimits;
}
capabilityPolicy(): ImageCdnCapabilityPolicy {
return this.#capability;
}
}
export function buildImageCandidateGeometry(
input: Readonly<{
width: number;
height: number;
responsiveWidths: readonly number[];
dprs: readonly number[];
}>,
): readonly ImageCandidateGeometry[] {
const candidates = new Map<number, ImageCandidateGeometry>();
for (const cssWidth of input.responsiveWidths) {
const cssHeight = Math.max(
1,
Math.round((cssWidth * input.height) / input.width),
);
for (const dpr of input.dprs) {
const pixelWidth = cssWidth * dpr;
const pixelHeight = cssHeight * dpr;
if (
!Number.isSafeInteger(pixelWidth) ||
!Number.isSafeInteger(pixelHeight)
) {
throw new TypeError(
"Image CDN preset produces fractional pixels.",
);
}
const existing = candidates.get(pixelWidth);
if (!existing || (dpr === 1 && existing.dpr !== 1)) {
const pixels = pixelWidth * pixelHeight;
candidates.set(
pixelWidth,
Object.freeze({
cssWidth,
cssHeight,
dpr,
pixelWidth,
pixelHeight,
pixels,
decodedBytes: pixels * 4,
}),
);
}
}
}
return Object.freeze(
[...candidates.values()].sort(
(left, right) => left.pixelWidth - right.pixelWidth,
),
);
}
function snapshotApplicationOrigin(input: string): string {
if (typeof input !== "string") {
throw new TypeError(
"Image CDN application origin policy is invalid.",
);
}
let parsed: URL;
try {
parsed = new URL(input);
} catch {
throw new TypeError(
"Image CDN application origin policy is invalid.",
);
}
if (
parsed.protocol !== "https:" ||
parsed.username !== "" ||
parsed.password !== "" ||
parsed.pathname !== "/" ||
parsed.search !== "" ||
parsed.hash !== "" ||
input !== parsed.origin
) {
throw new TypeError(
"Image CDN application origin policy is invalid.",
);
}
return parsed.origin;
}
function snapshotOrigin(
input: ImageCdnOriginPolicy,
): ResolvedImageCdnOrigin {
if (!hasExactOwnKeys(input, ORIGIN_KEYS)) {
throw new TypeError("Image CDN origin policy is invalid.");
}
let parsed: URL;
try {
parsed = new URL(input.origin);
} catch {
throw new TypeError("Image CDN origin policy is invalid.");
}
if (
!POLICY_TOKEN.test(input.originKey) ||
parsed.protocol !== "https:" ||
parsed.username !== "" ||
parsed.password !== "" ||
parsed.pathname !== "/" ||
parsed.search !== "" ||
parsed.hash !== "" ||
!PATH_PREFIX.test(input.assetPathPrefix) ||
input.assetPathPrefix.includes("//") ||
input.assetPathPrefix.includes("/../") ||
input.assetPathPrefix.includes("/./") ||
!positiveSafeInteger(input.minimumPublicMaxAgeSeconds) ||
input.minimumPublicMaxAgeSeconds > 315_360_000
) {
throw new TypeError("Image CDN origin policy is invalid.");
}
return Object.freeze({
originKey: input.originKey,
origin: parsed.origin,
assetPathPrefix: input.assetPathPrefix,
minimumPublicMaxAgeSeconds:
input.minimumPublicMaxAgeSeconds,
});
}
function snapshotPreset(
input: ImageCdnPresetPolicy,
hardLimits: ImageCdnHardLimits,
): ResolvedImageCdnPreset {
if (
!hasExactOwnKeys(input, PRESET_KEYS) ||
!input.reference ||
typeof input.reference !== "object" ||
!ISSUED_PRESET_REFERENCES.has(input.reference) ||
!POLICY_TOKEN.test(input.bindingId) ||
!positiveSafeInteger(input.width) ||
input.width > hardLimits.maxCssDimension ||
!positiveSafeInteger(input.height) ||
input.height > hardLimits.maxCssDimension ||
!IMAGE_FITS.includes(input.fit) ||
!Array.isArray(input.dprs) ||
input.dprs.length < 1 ||
!Array.isArray(input.responsiveWidths) ||
input.responsiveWidths.length < 1 ||
!Array.isArray(input.formats) ||
input.formats.length < 1 ||
input.formats.length > IMAGE_FORMATS.length ||
!positiveSafeInteger(input.quality) ||
input.quality > hardLimits.maxQuality ||
!SAFE_SIZES.test(input.sizes) ||
hasControlCharacters(input.sizes) ||
!["eager", "lazy"].includes(input.loading) ||
!["async", "sync"].includes(input.decoding) ||
!["high", "low", "auto"].includes(input.fetchPriority) ||
![
"no-referrer",
"strict-origin-when-cross-origin",
].includes(input.referrerPolicy) ||
!["NONE", "PRIMARY_REQUIRED"].includes(input.probeMode) ||
typeof input.allowUpscale !== "boolean" ||
!positiveSafeInteger(input.maxTransformedPixels) ||
input.maxTransformedPixels > hardLimits.maxTransformedPixels ||
!positiveSafeInteger(input.maxDecodedBytes) ||
input.maxDecodedBytes > hardLimits.maxDecodedBytes ||
!positiveSafeInteger(input.maxEncodedBytes) ||
input.maxEncodedBytes > hardLimits.maxEncodedBytes ||
(input.fetchPriority === "high" && input.loading !== "eager")
) {
throw new TypeError("Image CDN preset policy is invalid.");
}
const dprs = sortedUniqueNumbers(input.dprs);
const responsiveWidths =
sortedUniqueNumbers(input.responsiveWidths);
const formats: ImageOutputFormat[] = [
...new Set<ImageOutputFormat>(input.formats),
];
if (
dprs.length !== input.dprs.length ||
responsiveWidths.length > hardLimits.maxCandidateCount ||
formats.length !== input.formats.length ||
!dprs.includes(1) ||
!responsiveWidths.includes(input.width) ||
dprs.some(
(dpr) =>
!positiveDpr(dpr) ||
dpr > hardLimits.maxDpr,
) ||
responsiveWidths.some(
(width) =>
!positiveSafeInteger(width) ||
width > hardLimits.maxCssDimension,
) ||
formats.some(
(format) =>
!IMAGE_FORMATS.includes(format) ||
hardLimits.formatQualityCeilings[format] === undefined ||
input.quality >
(hardLimits.formatQualityCeilings[format] ?? 0),
)
) {
throw new TypeError("Image CDN preset policy is invalid.");
}
const candidates = buildImageCandidateGeometry({
width: input.width,
height: input.height,
responsiveWidths,
dprs,
});
if (
candidates.length < 1 ||
candidates.length > hardLimits.maxCandidateCount ||
candidates.some(
(candidate) =>
candidate.pixelWidth >
hardLimits.maxIntrinsicWidth ||
candidate.pixelHeight >
hardLimits.maxIntrinsicHeight ||
candidate.pixels > input.maxTransformedPixels ||
candidate.decodedBytes > input.maxDecodedBytes,
)
) {
throw new TypeError("Image CDN preset exceeds its pixel budget.");
}
return Object.freeze({
reference: input.reference,
bindingId: input.bindingId,
width: input.width,
height: input.height,
fit: input.fit,
dprs: Object.freeze(dprs),
responsiveWidths: Object.freeze(responsiveWidths),
quality: input.quality,
formats: Object.freeze(formats),
sizes: input.sizes,
loading: input.loading,
decoding: input.decoding,
fetchPriority: input.fetchPriority,
referrerPolicy: input.referrerPolicy,
probeMode: input.probeMode,
allowUpscale: input.allowUpscale,
maxTransformedPixels: input.maxTransformedPixels,
maxDecodedBytes: input.maxDecodedBytes,
maxEncodedBytes: input.maxEncodedBytes,
candidates,
});
}
function snapshotHardLimits(
input: ImageCdnHardLimits,
): ImageCdnHardLimits {
const ceilings = IMAGE_CDN_IMPLEMENTATION_CEILINGS;
if (
!hasExactOwnKeys(input, HARD_LIMIT_KEYS) ||
!positiveSafeInteger(input.maxIntrinsicWidth) ||
input.maxIntrinsicWidth > ceilings.maxIntrinsicWidth ||
!positiveSafeInteger(input.maxIntrinsicHeight) ||
input.maxIntrinsicHeight > ceilings.maxIntrinsicHeight ||
!positiveSafeInteger(input.maxSourcePixels) ||
input.maxSourcePixels > ceilings.maxSourcePixels ||
!positiveSafeInteger(input.maxCssDimension) ||
input.maxCssDimension > input.maxIntrinsicWidth ||
input.maxCssDimension > ceilings.maxCssDimension ||
!positiveDpr(input.maxDpr) ||
input.maxDpr > ceilings.maxDpr ||
!positiveSafeInteger(input.maxQuality) ||
input.maxQuality > ceilings.maxQuality ||
!positiveSafeInteger(input.maxCandidateCount) ||
input.maxCandidateCount > ceilings.maxCandidateCount ||
!positiveSafeInteger(input.maxTransformedPixels) ||
input.maxTransformedPixels > ceilings.maxTransformedPixels ||
!positiveSafeInteger(input.maxDecodedBytes) ||
input.maxDecodedBytes > ceilings.maxDecodedBytes ||
!positiveSafeInteger(input.maxEncodedBytes) ||
input.maxEncodedBytes > ceilings.maxEncodedBytes ||
!positiveSafeInteger(input.maxUrlLength) ||
input.maxUrlLength > ceilings.maxUrlLength ||
!positiveSafeInteger(input.maxCapabilityLifetimeMs) ||
input.maxCapabilityLifetimeMs >
ceilings.maxCapabilityLifetimeMs ||
!nonNegativeSafeInteger(input.maxClockSkewMs) ||
input.maxClockSkewMs > ceilings.maxClockSkewMs ||
!nonNegativeSafeInteger(input.minCapabilityRemainingMs) ||
input.minCapabilityRemainingMs >
input.maxCapabilityLifetimeMs ||
input.minCapabilityRemainingMs >
ceilings.maxMinimumCapabilityRemainingMs ||
!positiveSafeInteger(input.maxPresetBindingsPerCapability) ||
input.maxPresetBindingsPerCapability >
ceilings.maxPresetBindingsPerCapability ||
!positiveSafeInteger(
input.maxConcurrentCapabilityVerifications,
) ||
input.maxConcurrentCapabilityVerifications >
ceilings.maxConcurrentCapabilityVerifications ||
!Array.isArray(input.allowedSourceMediaTypes) ||
input.allowedSourceMediaTypes.length < 1 ||
!input.formatQualityCeilings ||
typeof input.formatQualityCeilings !== "object" ||
Array.isArray(input.formatQualityCeilings)
) {
throw new TypeError("Image CDN hard limits are invalid.");
}
const allowedSourceMediaTypes = [
...new Set(input.allowedSourceMediaTypes),
];
if (
allowedSourceMediaTypes.length !==
input.allowedSourceMediaTypes.length ||
allowedSourceMediaTypes.some(
(mediaType) => !IMAGE_MEDIA_TYPES.includes(mediaType),
)
) {
throw new TypeError("Image CDN source media policy is invalid.");
}
const formatQualityCeilings:
Partial<Record<ImageOutputFormat, number>> = {};
for (const [format, ceiling] of Object.entries(
input.formatQualityCeilings,
)) {
if (
!IMAGE_FORMATS.includes(format as ImageOutputFormat) ||
!positiveSafeInteger(ceiling) ||
ceiling > input.maxQuality
) {
throw new TypeError("Image CDN format ceiling is invalid.");
}
formatQualityCeilings[format as ImageOutputFormat] = ceiling;
}
return Object.freeze({
maxIntrinsicWidth: input.maxIntrinsicWidth,
maxIntrinsicHeight: input.maxIntrinsicHeight,
maxSourcePixels: input.maxSourcePixels,
maxCssDimension: input.maxCssDimension,
maxDpr: input.maxDpr,
maxQuality: input.maxQuality,
maxCandidateCount: input.maxCandidateCount,
maxTransformedPixels: input.maxTransformedPixels,
maxDecodedBytes: input.maxDecodedBytes,
maxEncodedBytes: input.maxEncodedBytes,
maxUrlLength: input.maxUrlLength,
maxCapabilityLifetimeMs: input.maxCapabilityLifetimeMs,
maxClockSkewMs: input.maxClockSkewMs,
minCapabilityRemainingMs: input.minCapabilityRemainingMs,
maxPresetBindingsPerCapability:
input.maxPresetBindingsPerCapability,
maxConcurrentCapabilityVerifications:
input.maxConcurrentCapabilityVerifications,
allowedSourceMediaTypes: Object.freeze(
allowedSourceMediaTypes,
),
formatQualityCeilings:
Object.freeze(formatQualityCeilings),
});
}
function snapshotCapabilityPolicy(
input: ImageCdnCapabilityPolicy,
): ImageCdnCapabilityPolicy {
if (
!hasExactOwnKeys(input, CAPABILITY_KEYS) ||
!POLICY_TOKEN.test(input.issuer) ||
!Array.isArray(input.acceptedKeyIds) ||
input.acceptedKeyIds.length < 1 ||
input.acceptedKeyIds.length >
IMAGE_CDN_IMPLEMENTATION_CEILINGS.maxAcceptedKeyIds
) {
throw new TypeError("Image CDN capability policy is invalid.");
}
const acceptedKeyIds = [...input.acceptedKeyIds];
if (
new Set(acceptedKeyIds).size !== acceptedKeyIds.length ||
acceptedKeyIds.some((keyId) => !POLICY_TOKEN.test(keyId))
) {
throw new TypeError("Image CDN capability policy is invalid.");
}
return Object.freeze({
issuer: input.issuer,
acceptedKeyIds: Object.freeze(acceptedKeyIds),
});
}
function sortedUniqueNumbers(values: readonly number[]): number[] {
return [...new Set(values)].sort((left, right) => left - right);
}
function positiveDpr(value: number): boolean {
return (
Number.isFinite(value) &&
value > 0 &&
Number.isSafeInteger(value * 100)
);
}
function positiveSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
function nonNegativeSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
function hasExactOwnKeys(
input: unknown,
keys: readonly string[],
): boolean {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return false;
}
const actual = Object.keys(input).sort();
const expected = [...keys].sort();
return (
actual.length === expected.length &&
actual.every((key, index) => key === expected[index])
);
}
function hasControlCharacters(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint < 0x20 || codePoint === 0x7f;
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,734 @@
import type { ImageRasterMediaType } from "../../../application/ports/browser-transfer/image-cdn.ts";
export type StaticImageHeaderMetadata = Readonly<{
width: number;
height: number;
}>;
/**
* Parses only the deliberately supported static-image subset. Unknown,
* ambiguous, animated and structurally malformed containers fail closed
* before a native decoder can allocate an output surface.
*/
export function parseStaticImageHeaderMetadata(
bytes: Uint8Array,
mediaType: ImageRasterMediaType,
): StaticImageHeaderMetadata | null {
switch (mediaType) {
case "image/avif":
return parseAvif(bytes);
case "image/jpeg":
return parseJpeg(bytes);
case "image/png":
return parsePng(bytes);
case "image/webp":
return parseWebp(bytes);
}
}
function parsePng(
bytes: Uint8Array,
): StaticImageHeaderMetadata | null {
const signature = [
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
];
if (
bytes.byteLength < 33 ||
!signature.every((value, index) => bytes[index] === value)
) {
return null;
}
const view = dataView(bytes);
let offset = 8;
let dimensions: StaticImageHeaderMetadata | null = null;
let chunkIndex = 0;
let ended = false;
let imageDataSeen = false;
while (offset < bytes.byteLength) {
if (offset + 12 > bytes.byteLength) return null;
const length = view.getUint32(offset);
const type = ascii(bytes, offset + 4, offset + 8);
const payloadStart = offset + 8;
const payloadEnd = payloadStart + length;
const chunkEnd = payloadEnd + 4;
if (
!Number.isSafeInteger(chunkEnd) ||
chunkEnd > bytes.byteLength
) {
return null;
}
if (chunkIndex === 0 && (type !== "IHDR" || length !== 13)) {
return null;
}
if (type === "IHDR") {
if (dimensions || length !== 13) return null;
const width = view.getUint32(payloadStart);
const height = view.getUint32(payloadStart + 4);
dimensions = validDimensions(width, height);
const bitDepth = bytes[payloadStart + 8];
const colorType = bytes[payloadStart + 9];
const compression = bytes[payloadStart + 10];
const filter = bytes[payloadStart + 11];
const interlace = bytes[payloadStart + 12];
if (
!dimensions ||
bitDepth === undefined ||
colorType === undefined ||
!validPngColorDepth(colorType, bitDepth) ||
compression !== 0 ||
filter !== 0 ||
(interlace !== 0 && interlace !== 1)
) {
return null;
}
}
if (type === "acTL" || type === "fcTL" || type === "fdAT") {
return null;
}
if (type === "IDAT") imageDataSeen = true;
if (type === "IEND") {
if (
length !== 0 ||
!imageDataSeen ||
chunkEnd !== bytes.byteLength
) {
return null;
}
ended = true;
}
offset = chunkEnd;
chunkIndex += 1;
if (ended) break;
}
return ended && dimensions ? dimensions : null;
}
function validPngColorDepth(
colorType: number,
bitDepth: number,
): boolean {
const supportedDepths: Readonly<Record<number, readonly number[]>> =
{
0: [1, 2, 4, 8, 16],
2: [8, 16],
3: [1, 2, 4, 8],
4: [8, 16],
6: [8, 16],
};
return supportedDepths[colorType]?.includes(bitDepth) ?? false;
}
function parseJpeg(
bytes: Uint8Array,
): StaticImageHeaderMetadata | null {
if (
bytes.byteLength < 4 ||
bytes[0] !== 0xff ||
bytes[1] !== 0xd8
) {
return null;
}
const supportedStartOfFrame = new Set([0xc0, 0xc1, 0xc2]);
const unsupportedStartOfFrame = new Set([
0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
]);
const view = dataView(bytes);
let dimensions: StaticImageHeaderMetadata | null = null;
let offset = 2;
while (offset < bytes.byteLength) {
if (bytes[offset] !== 0xff) return null;
while (offset < bytes.byteLength && bytes[offset] === 0xff) {
offset += 1;
}
if (offset >= bytes.byteLength) return null;
const marker = bytes[offset];
offset += 1;
if (marker === undefined || marker === 0x00) return null;
if (marker === 0xd9) return null;
if (marker === 0xda) return dimensions;
if (
marker === 0xd8 ||
marker === 0x01 ||
(marker >= 0xd0 && marker <= 0xd7)
) {
continue;
}
if (offset + 2 > bytes.byteLength) return null;
const segmentLength = view.getUint16(offset);
if (segmentLength < 2) return null;
const segmentEnd = offset + segmentLength;
if (segmentEnd > bytes.byteLength) return null;
if (unsupportedStartOfFrame.has(marker)) return null;
if (supportedStartOfFrame.has(marker)) {
if (dimensions || segmentLength < 8) return null;
const precision = bytes[offset + 2];
const height = view.getUint16(offset + 3);
const width = view.getUint16(offset + 5);
const componentCount = bytes[offset + 7];
dimensions = validDimensions(width, height);
if (
!dimensions ||
precision !== 8 ||
(componentCount !== 1 && componentCount !== 3) ||
segmentLength !== 8 + componentCount * 3
) {
return null;
}
}
offset = segmentEnd;
}
return null;
}
function parseWebp(
bytes: Uint8Array,
): StaticImageHeaderMetadata | null {
if (
bytes.byteLength < 20 ||
ascii(bytes, 0, 4) !== "RIFF" ||
ascii(bytes, 8, 12) !== "WEBP"
) {
return null;
}
const view = dataView(bytes);
const riffLength = view.getUint32(4, true) + 8;
if (riffLength !== bytes.byteLength) return null;
let offset = 12;
let dimensions: StaticImageHeaderMetadata | null = null;
let imagePayloadCount = 0;
let extendedHeaderSeen = false;
let chunkIndex = 0;
while (offset < bytes.byteLength) {
if (offset + 8 > bytes.byteLength) return null;
const type = ascii(bytes, offset, offset + 4);
const length = view.getUint32(offset + 4, true);
const payloadStart = offset + 8;
const payloadEnd = payloadStart + length;
const chunkEnd = payloadEnd + (length % 2);
if (
!Number.isSafeInteger(chunkEnd) ||
chunkEnd > bytes.byteLength
) {
return null;
}
if (
length % 2 === 1 &&
bytes[payloadEnd] !== 0
) {
return null;
}
if (type === "ANIM" || type === "ANMF") return null;
let candidate: StaticImageHeaderMetadata | null = null;
if (type === "VP8X") {
if (
chunkIndex !== 0 ||
extendedHeaderSeen ||
length !== 10 ||
bytes[payloadStart] === undefined ||
(bytes[payloadStart] & 0xc3) !== 0
) {
return null;
}
extendedHeaderSeen = true;
candidate = validDimensions(
readUint24LittleEndian(bytes, payloadStart + 4) + 1,
readUint24LittleEndian(bytes, payloadStart + 7) + 1,
);
} else if (type === "VP8 ") {
imagePayloadCount += 1;
if (
length < 10 ||
bytes[payloadStart + 3] !== 0x9d ||
bytes[payloadStart + 4] !== 0x01 ||
bytes[payloadStart + 5] !== 0x2a
) {
return null;
}
candidate = validDimensions(
view.getUint16(payloadStart + 6, true) & 0x3fff,
view.getUint16(payloadStart + 8, true) & 0x3fff,
);
} else if (type === "VP8L") {
imagePayloadCount += 1;
if (length < 5 || bytes[payloadStart] !== 0x2f) {
return null;
}
const byte1 = bytes[payloadStart + 1];
const byte2 = bytes[payloadStart + 2];
const byte3 = bytes[payloadStart + 3];
const byte4 = bytes[payloadStart + 4];
if (
byte1 === undefined ||
byte2 === undefined ||
byte3 === undefined ||
byte4 === undefined ||
(byte4 & 0xe0) !== 0
) {
return null;
}
candidate = validDimensions(
1 + byte1 + ((byte2 & 0x3f) << 8),
1 +
((byte2 & 0xc0) >> 6) +
(byte3 << 2) +
((byte4 & 0x0f) << 10),
);
}
if (candidate) {
if (
dimensions &&
(dimensions.width !== candidate.width ||
dimensions.height !== candidate.height)
) {
return null;
}
dimensions = candidate;
}
offset = chunkEnd;
chunkIndex += 1;
}
return offset === bytes.byteLength &&
dimensions &&
imagePayloadCount === 1
? dimensions
: null;
}
function parseAvif(
bytes: Uint8Array,
): StaticImageHeaderMetadata | null {
if (bytes.byteLength < 24) return null;
const boxes = parseBoxes(bytes, 0, bytes.byteLength);
if (!boxes || boxes.length < 2 || boxes[0]?.type !== "ftyp") {
return null;
}
const fileType = boxes[0];
const fileTypeLength = fileType
? fileType.payloadEnd - fileType.payloadStart
: 0;
if (
!fileType ||
fileTypeLength < 8 ||
(fileTypeLength - 8) % 4 !== 0
) {
return null;
}
const brands: string[] = [
ascii(bytes, fileType.payloadStart, fileType.payloadStart + 4),
];
for (
let offset = fileType.payloadStart + 8;
offset + 4 <= fileType.payloadEnd;
offset += 4
) {
brands.push(ascii(bytes, offset, offset + 4));
}
if (!brands.includes("avif") || brands.includes("avis")) {
return null;
}
if (
boxes.some((box) => box.type === "moov") ||
!boxes.some(
(box) =>
box.type === "mdat" &&
box.payloadEnd > box.payloadStart,
)
) {
return null;
}
const metadataBoxes = boxes.filter((box) => box.type === "meta");
const metadataBox = metadataBoxes[0];
if (
metadataBoxes.length !== 1 ||
!metadataBox ||
metadataBox.payloadStart + 4 > metadataBox.payloadEnd ||
!zeroFullBoxFlags(bytes, metadataBox.payloadStart)
) {
return null;
}
const metadataChildren = parseBoxes(
bytes,
metadataBox.payloadStart + 4,
metadataBox.payloadEnd,
);
if (!metadataChildren) return null;
const state: AvifMetadataState = {
associations: new Map(),
itemTypes: new Map(),
primaryItemId: null,
properties: new Map(),
propertyCount: 0,
};
let itemInfoSeen = false;
let itemPropertiesSeen = false;
for (const box of metadataChildren) {
if (box.type === "pitm") {
const primaryItemId = parseAvifPrimaryItem(bytes, box);
if (
primaryItemId === null ||
state.primaryItemId !== null
) {
return null;
}
state.primaryItemId = primaryItemId;
} else if (box.type === "iinf") {
if (itemInfoSeen || !parseAvifItemInfo(bytes, box, state)) {
return null;
}
itemInfoSeen = true;
} else if (box.type === "iprp") {
if (
itemPropertiesSeen ||
!parseAvifItemProperties(bytes, box, state)
) {
return null;
}
itemPropertiesSeen = true;
}
}
const primaryItemId = state.primaryItemId;
if (
primaryItemId === null ||
state.itemTypes.get(primaryItemId) !== "av01"
) {
return null;
}
const associatedProperties = state.associations.get(primaryItemId);
if (!associatedProperties) return null;
const associatedExtents: StaticImageHeaderMetadata[] = [];
const seenProperties = new Set<number>();
for (const propertyIndex of associatedProperties) {
if (
propertyIndex < 1 ||
propertyIndex > state.propertyCount ||
seenProperties.has(propertyIndex)
) {
return null;
}
seenProperties.add(propertyIndex);
const dimensions = state.properties.get(propertyIndex);
if (dimensions) associatedExtents.push(dimensions);
}
return associatedExtents.length === 1
? (associatedExtents[0] ?? null)
: null;
}
type IsoBox = Readonly<{
type: string;
payloadStart: number;
payloadEnd: number;
}>;
type AvifMetadataState = {
primaryItemId: number | null;
itemTypes: Map<number, string>;
properties: Map<number, StaticImageHeaderMetadata>;
associations: Map<number, readonly number[]>;
propertyCount: number;
};
function parseAvifPrimaryItem(
bytes: Uint8Array,
box: IsoBox,
): number | null {
const version = bytes[box.payloadStart];
const view = dataView(bytes);
if (!zeroFullBoxFlags(bytes, box.payloadStart)) return null;
if (
version === 0 &&
box.payloadEnd - box.payloadStart === 6
) {
return view.getUint16(box.payloadStart + 4);
}
if (
version === 1 &&
box.payloadEnd - box.payloadStart === 8
) {
return view.getUint32(box.payloadStart + 4);
}
return null;
}
function parseAvifItemInfo(
bytes: Uint8Array,
box: IsoBox,
state: AvifMetadataState,
): boolean {
const start = box.payloadStart;
const end = box.payloadEnd;
const version = bytes[start];
if (
(version !== 0 && version !== 1) ||
!zeroFullBoxFlags(bytes, start)
) {
return false;
}
const entryBytes = version === 0 ? 2 : 4;
if (start + 4 + entryBytes > end) return false;
const view = dataView(bytes);
const declaredEntries =
entryBytes === 2
? view.getUint16(start + 4)
: view.getUint32(start + 4);
const entriesStart = start + 4 + entryBytes;
const boxes = parseBoxes(bytes, entriesStart, end);
if (
!boxes ||
boxes.length !== declaredEntries ||
boxes.some((entry) => entry.type !== "infe")
) {
return false;
}
for (const entry of boxes) {
const itemVersion = bytes[entry.payloadStart];
if (!zeroFullBoxFlags(bytes, entry.payloadStart)) return false;
let itemId: number;
let itemTypeOffset: number;
if (itemVersion === 2) {
if (entry.payloadStart + 12 > entry.payloadEnd) return false;
itemId = view.getUint16(entry.payloadStart + 4);
itemTypeOffset = entry.payloadStart + 8;
} else if (itemVersion === 3) {
if (entry.payloadStart + 14 > entry.payloadEnd) return false;
itemId = view.getUint32(entry.payloadStart + 4);
itemTypeOffset = entry.payloadStart + 10;
} else {
return false;
}
const itemType = ascii(
bytes,
itemTypeOffset,
itemTypeOffset + 4,
);
if (itemType === "grid" || itemType === "iovl") {
return false;
}
if (itemId === 0 || state.itemTypes.has(itemId)) return false;
state.itemTypes.set(itemId, itemType);
}
return true;
}
function parseAvifItemProperties(
bytes: Uint8Array,
box: IsoBox,
state: AvifMetadataState,
): boolean {
const boxes = parseBoxes(
bytes,
box.payloadStart,
box.payloadEnd,
);
if (!boxes) return false;
const propertyContainers = boxes.filter(
(child) => child.type === "ipco",
);
const associationBoxes = boxes.filter(
(child) => child.type === "ipma",
);
const propertyContainer = propertyContainers[0];
if (
propertyContainers.length !== 1 ||
associationBoxes.length < 1 ||
!propertyContainer
) {
return false;
}
const properties = parseBoxes(
bytes,
propertyContainer.payloadStart,
propertyContainer.payloadEnd,
);
if (!properties) return false;
state.propertyCount = properties.length;
for (const [offset, property] of properties.entries()) {
if (property.type !== "ispe") continue;
if (
property.payloadEnd - property.payloadStart !== 12 ||
bytes[property.payloadStart] !== 0 ||
bytes[property.payloadStart + 1] !== 0 ||
bytes[property.payloadStart + 2] !== 0 ||
bytes[property.payloadStart + 3] !== 0
) {
return false;
}
const view = dataView(bytes);
const dimensions = validDimensions(
view.getUint32(property.payloadStart + 4),
view.getUint32(property.payloadStart + 8),
);
if (!dimensions) return false;
state.properties.set(offset + 1, dimensions);
}
return associationBoxes.every((association) =>
parseAvifPropertyAssociations(bytes, association, state)
);
}
function parseAvifPropertyAssociations(
bytes: Uint8Array,
box: IsoBox,
state: AvifMetadataState,
): boolean {
const start = box.payloadStart;
const end = box.payloadEnd;
if (start + 8 > end) return false;
const version = bytes[start];
if (version !== 0 && version !== 1) return false;
const flags =
((bytes[start + 1] ?? 0) << 16) |
((bytes[start + 2] ?? 0) << 8) |
(bytes[start + 3] ?? 0);
if ((flags & ~1) !== 0) return false;
const wideAssociation = (flags & 1) === 1;
const view = dataView(bytes);
const entryCount = view.getUint32(start + 4);
let offset = start + 8;
for (let entry = 0; entry < entryCount; entry += 1) {
const itemIdBytes = version === 0 ? 2 : 4;
if (offset + itemIdBytes + 1 > end) return false;
const itemId =
itemIdBytes === 2
? view.getUint16(offset)
: view.getUint32(offset);
offset += itemIdBytes;
const associationCount = bytes[offset];
if (associationCount === undefined) return false;
offset += 1;
const propertyIndices: number[] = [];
for (
let association = 0;
association < associationCount;
association += 1
) {
const associationBytes = wideAssociation ? 2 : 1;
if (offset + associationBytes > end) return false;
const encoded =
associationBytes === 2
? view.getUint16(offset)
: (bytes[offset] ?? 0);
const propertyIndex =
encoded & (wideAssociation ? 0x7fff : 0x7f);
offset += associationBytes;
if (propertyIndex !== 0) propertyIndices.push(propertyIndex);
}
if (itemId === 0 || state.associations.has(itemId)) {
return false;
}
state.associations.set(itemId, propertyIndices);
}
return offset === end;
}
function parseBoxes(
bytes: Uint8Array,
start: number,
end: number,
): readonly IsoBox[] | null {
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
end > bytes.byteLength ||
start > end
) {
return null;
}
const boxes: IsoBox[] = [];
const view = dataView(bytes);
let offset = start;
while (offset < end) {
if (offset + 8 > end) return null;
const shortSize = view.getUint32(offset);
const type = ascii(bytes, offset + 4, offset + 8);
let boxSize = shortSize;
let headerSize = 8;
if (shortSize === 0) return null;
if (shortSize === 1) {
if (offset + 16 > end) return null;
const longSize = view.getBigUint64(offset + 8);
if (longSize > BigInt(Number.MAX_SAFE_INTEGER)) return null;
boxSize = Number(longSize);
headerSize = 16;
}
if (boxSize < headerSize || offset + boxSize > end) {
return null;
}
boxes.push(
Object.freeze({
type,
payloadStart: offset + headerSize,
payloadEnd: offset + boxSize,
}),
);
offset += boxSize;
}
return offset === end ? boxes : null;
}
function zeroFullBoxFlags(
bytes: Uint8Array,
offset: number,
): boolean {
return (
bytes[offset + 1] === 0 &&
bytes[offset + 2] === 0 &&
bytes[offset + 3] === 0
);
}
function validDimensions(
width: number,
height: number,
): StaticImageHeaderMetadata | null {
return Number.isSafeInteger(width) &&
Number.isSafeInteger(height) &&
width > 0 &&
height > 0
? Object.freeze({ width, height })
: null;
}
function readUint24LittleEndian(
bytes: Uint8Array,
offset: number,
): number {
const byte0 = bytes[offset];
const byte1 = bytes[offset + 1];
const byte2 = bytes[offset + 2];
if (
byte0 === undefined ||
byte1 === undefined ||
byte2 === undefined
) {
return Number.NaN;
}
return byte0 | (byte1 << 8) | (byte2 << 16);
}
function ascii(
bytes: Uint8Array,
start: number,
end: number,
): string {
let value = "";
for (let offset = start; offset < end; offset += 1) {
const byte = bytes[offset];
if (byte === undefined) return "";
value += String.fromCharCode(byte);
}
return value;
}
function dataView(bytes: Uint8Array): DataView {
return new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
);
}
@@ -0,0 +1,34 @@
export {
createBrowserImageProbe,
type BrowserImageProbeDependencies,
type DecodedImageFacade,
type ImageProbeScheduler,
} from "./browser-image-probe.ts";
export {
IMAGE_FORMAT_MEDIA_TYPE,
IMAGE_CDN_IMPLEMENTATION_CEILINGS,
ImageCdnPolicyRegistry,
buildImageCandidateGeometry,
imageCdnPresetReference,
type ImageCandidateGeometry,
type ImageCdnCapabilityPolicy,
type ImageCdnHardLimits,
type ImageCdnOriginPolicy,
type ImageCdnPolicyRegistryOptions,
type ImageCdnPresetPolicy,
type ResolvedImageCdnOrigin,
type ResolvedImageCdnPreset,
} from "./image-cdn-policy.ts";
export {
canonicalImageCapabilityPayload,
computeImageCapabilityBindingDigestHex,
createImageCdnRuntime,
DEFAULT_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS,
MAX_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS,
type ImageCapabilityVerificationScheduler,
type ImageCdnRuntimeDependencies,
} from "./image-cdn-runtime.ts";
export {
createP256ImageCapabilityVerifier,
type P256ImageCapabilityVerifierOptions,
} from "./p256-image-capability-verifier.ts";
@@ -0,0 +1,134 @@
import type {
ImageCapabilityVerificationRequest,
ImageCapabilityVerifier,
} from "../../../application/ports/browser-transfer/image-cdn.ts";
export type P256ImageCapabilityVerifierOptions = Readonly<{
subtle: Pick<SubtleCrypto, "verify">;
publicKeys: readonly Readonly<{
keyId: string;
key: CryptoKey;
}>[];
}>;
const KEY_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
/**
* Concrete verifier for backend-issued ECDSA P-256/SHA-256 capabilities.
* Signatures use the 64-byte IEEE-P1363 representation required by this
* contract, encoded as unpadded base64url.
*/
export function createP256ImageCapabilityVerifier(
options: P256ImageCapabilityVerifierOptions,
): ImageCapabilityVerifier {
if (
!options ||
typeof options !== "object" ||
!Array.isArray(options.publicKeys) ||
options.publicKeys.length < 1 ||
options.publicKeys.length > 16
) {
throw new TypeError(
"Image capability verifier configuration is invalid.",
);
}
const verify = options.subtle.verify.bind(options.subtle);
const keys = new Map<string, CryptoKey>();
for (const binding of options.publicKeys) {
const algorithmName =
binding.key.algorithm &&
typeof binding.key.algorithm === "object" &&
"name" in binding.key.algorithm
? binding.key.algorithm.name
: null;
const namedCurve =
binding.key.algorithm &&
typeof binding.key.algorithm === "object" &&
"namedCurve" in binding.key.algorithm
? binding.key.algorithm.namedCurve
: null;
if (
!KEY_ID.test(binding.keyId) ||
binding.key.type !== "public" ||
algorithmName !== "ECDSA" ||
namedCurve !== "P-256" ||
!binding.key.usages.includes("verify") ||
keys.has(binding.keyId)
) {
throw new TypeError(
"Image capability public key binding is invalid.",
);
}
keys.set(binding.keyId, binding.key);
}
return Object.freeze({
acceptsKey(keyId: string): boolean {
return KEY_ID.test(keyId) && keys.has(keyId);
},
async verify(
request: ImageCapabilityVerificationRequest,
): Promise<boolean> {
if (
request.algorithm !== "ECDSA_P256_SHA256" ||
!KEY_ID.test(request.keyId) ||
!(request.canonicalPayload instanceof Uint8Array) ||
request.canonicalPayload.byteLength < 1 ||
request.canonicalPayload.byteLength > 8_192
) {
return false;
}
const key = keys.get(request.keyId);
if (!key) return false;
const signature = decodeBase64Url(
request.signatureBase64Url,
);
if (!signature || signature.byteLength !== 64) {
return false;
}
try {
const signatureBytes = new Uint8Array(signature.byteLength);
signatureBytes.set(signature);
const payloadBytes = new Uint8Array(
request.canonicalPayload.byteLength,
);
payloadBytes.set(request.canonicalPayload);
return await verify(
{ name: "ECDSA", hash: "SHA-256" },
key,
signatureBytes.buffer,
payloadBytes.buffer,
);
} catch {
return false;
}
},
});
}
function decodeBase64Url(value: string): Uint8Array | null {
if (
!/^[A-Za-z0-9_-]+$/u.test(value) ||
value.length % 4 === 1
) {
return null;
}
const alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const output: number[] = [];
let accumulator = 0;
let bitCount = 0;
for (const character of value) {
const index = alphabet.indexOf(character);
if (index < 0) return null;
accumulator = (accumulator << 6) | index;
bitCount += 6;
if (bitCount >= 8) {
bitCount -= 8;
output.push((accumulator >> bitCount) & 0xff);
accumulator &= (1 << bitCount) - 1;
}
}
if (bitCount > 0 && accumulator !== 0) return null;
return Uint8Array.from(output);
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./image-cdn/index.ts";
export * from "./presigned/index.ts";
export * from "./resumable-upload/index.ts";
@@ -0,0 +1,204 @@
const INITIAL_STATE = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const ROUND_CONSTANTS = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
export type StreamingSha256Verifier = Readonly<{
update(bytes: Uint8Array): void;
verify(): boolean;
}>;
export function createStreamingSha256Verifier(
expectedSha256: string,
): StreamingSha256Verifier {
const accumulator = new Sha256Accumulator();
let verified = false;
return Object.freeze({
update(bytes: Uint8Array) {
if (verified) throw new TypeError("SHA-256 verifier is finalized.");
accumulator.update(bytes);
},
verify() {
if (verified) throw new TypeError("SHA-256 verifier is finalized.");
verified = true;
return constantTimeHexEqual(
accumulator.digestHex(),
expectedSha256.toLowerCase(),
);
},
});
}
export function sha256Hex(bytes: Uint8Array): string {
const accumulator = new Sha256Accumulator();
accumulator.update(bytes);
return accumulator.digestHex();
}
class Sha256Accumulator {
readonly #state = new Uint32Array(INITIAL_STATE);
readonly #buffer = new Uint8Array(64);
readonly #schedule = new Uint32Array(64);
#bufferLength = 0;
#totalBytes = 0;
#finalized = false;
update(bytes: Uint8Array): void {
if (this.#finalized || !(bytes instanceof Uint8Array)) {
throw new TypeError("SHA-256 input is invalid.");
}
const nextTotal = this.#totalBytes + bytes.byteLength;
if (!Number.isSafeInteger(nextTotal)) {
throw new TypeError("SHA-256 input is too large.");
}
this.#totalBytes = nextTotal;
let offset = 0;
if (this.#bufferLength > 0) {
const available = 64 - this.#bufferLength;
const copied = Math.min(available, bytes.byteLength);
this.#buffer.set(bytes.subarray(0, copied), this.#bufferLength);
this.#bufferLength += copied;
offset += copied;
if (this.#bufferLength === 64) {
this.#compress(this.#buffer);
this.#bufferLength = 0;
}
}
while (offset + 64 <= bytes.byteLength) {
this.#compress(bytes.subarray(offset, offset + 64));
offset += 64;
}
if (offset < bytes.byteLength) {
const remainder = bytes.subarray(offset);
this.#buffer.set(remainder, 0);
this.#bufferLength = remainder.byteLength;
}
}
digestHex(): string {
if (this.#finalized) throw new TypeError("SHA-256 is finalized.");
this.#finalized = true;
const finalLength = this.#bufferLength < 56 ? 64 : 128;
const finalBlocks = new Uint8Array(finalLength);
finalBlocks.set(this.#buffer.subarray(0, this.#bufferLength));
finalBlocks[this.#bufferLength] = 0x80;
const bitLength = BigInt(this.#totalBytes) * 8n;
for (let index = 0; index < 8; index += 1) {
finalBlocks[finalLength - 1 - index] = Number(
(bitLength >> BigInt(index * 8)) & 0xffn,
);
}
for (let offset = 0; offset < finalLength; offset += 64) {
this.#compress(finalBlocks.subarray(offset, offset + 64));
}
return Array.from(this.#state, (word) =>
word.toString(16).padStart(8, "0"),
).join("");
}
#compress(block: Uint8Array): void {
const words = this.#schedule;
const view = new DataView(
block.buffer,
block.byteOffset,
block.byteLength,
);
for (let index = 0; index < 16; index += 1) {
words[index] = view.getUint32(index * 4, false);
}
for (let index = 16; index < 64; index += 1) {
const previous15 = words[index - 15] ?? 0;
const previous2 = words[index - 2] ?? 0;
const sigma0 =
rotateRight(previous15, 7) ^
rotateRight(previous15, 18) ^
(previous15 >>> 3);
const sigma1 =
rotateRight(previous2, 17) ^
rotateRight(previous2, 19) ^
(previous2 >>> 10);
words[index] =
((words[index - 16] ?? 0) +
sigma0 +
(words[index - 7] ?? 0) +
sigma1) >>>
0;
}
let a = this.#state[0] ?? 0;
let b = this.#state[1] ?? 0;
let c = this.#state[2] ?? 0;
let d = this.#state[3] ?? 0;
let e = this.#state[4] ?? 0;
let f = this.#state[5] ?? 0;
let g = this.#state[6] ?? 0;
let h = this.#state[7] ?? 0;
for (let index = 0; index < 64; index += 1) {
const sum1 =
rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
const choice = (e & f) ^ (~e & g);
const temporary1 =
(h +
sum1 +
choice +
(ROUND_CONSTANTS[index] ?? 0) +
(words[index] ?? 0)) >>>
0;
const sum0 =
rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
const majority = (a & b) ^ (a & c) ^ (b & c);
const temporary2 = (sum0 + majority) >>> 0;
h = g;
g = f;
f = e;
e = (d + temporary1) >>> 0;
d = c;
c = b;
b = a;
a = (temporary1 + temporary2) >>> 0;
}
this.#state[0] = ((this.#state[0] ?? 0) + a) >>> 0;
this.#state[1] = ((this.#state[1] ?? 0) + b) >>> 0;
this.#state[2] = ((this.#state[2] ?? 0) + c) >>> 0;
this.#state[3] = ((this.#state[3] ?? 0) + d) >>> 0;
this.#state[4] = ((this.#state[4] ?? 0) + e) >>> 0;
this.#state[5] = ((this.#state[5] ?? 0) + f) >>> 0;
this.#state[6] = ((this.#state[6] ?? 0) + g) >>> 0;
this.#state[7] = ((this.#state[7] ?? 0) + h) >>> 0;
}
}
function rotateRight(value: number, bits: number): number {
return (value >>> bits) | (value << (32 - bits));
}
function constantTimeHexEqual(left: string, right: string): boolean {
let mismatch = left.length ^ right.length;
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
mismatch |=
(left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
}
return mismatch === 0;
}
@@ -0,0 +1,18 @@
export {
createPresignedCapabilityHttpProvider,
type PresignedCapabilityHttpProvider,
type PresignedCapabilityHttpProviderOptions,
} from "./presigned-capability-http-provider.ts";
export {
createPresignedCapabilityVault,
createSingleUsePresignedReplayGuard,
type PresignedCapabilityBinding,
type PresignedCapabilityRegistration,
type PresignedCapabilityVault,
type PresignedHeaderBinding,
} from "./presigned-capability-vault.ts";
export {
createPresignedTransferExecutor,
type PresignedTransferExecutor,
type PresignedTransferExecutorOptions,
} from "./presigned-transfer-executor.ts";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
import type {
PresignedTransferBinding,
PresignedTransferCapability,
PresignedTransferCapabilityReceipt,
PresignedTransferMethod,
PresignedTransferReplayGuard,
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
export type PresignedHeaderBinding = Readonly<{
name: string;
value: string;
}>;
export type PresignedCapabilityRegistration = Readonly<{
capabilityReceipt: PresignedTransferCapabilityReceipt;
method: PresignedTransferMethod;
binding: PresignedTransferBinding;
href: string;
origin: string;
path: string;
allowedQueryParameters: readonly string[];
requestHeaders: readonly PresignedHeaderBinding[];
requiredResponseHeaders: readonly PresignedHeaderBinding[];
digestRequestHeader: string | null;
digestResponseHeader: string | null;
receiptResponseHeader: string | null;
expectedStatus: number;
expectedResponseByteLength: number | null;
mediaType: string;
byteLength: number;
maxBytes: number;
expectedSha256: string;
expiresAtEpochMs: number;
}>;
export type PresignedCapabilityBinding = Readonly<
PresignedCapabilityRegistration & {
capability: PresignedTransferCapability;
}
>;
export interface PresignedCapabilityVault {
register(
registration: PresignedCapabilityRegistration,
): BrowserDataResult<PresignedTransferCapability>;
resolve(
capability: PresignedTransferCapability,
): BrowserDataResult<PresignedCapabilityBinding>;
/**
* Atomically retires an exact identity after its single-use replay claim.
* The caller may keep the already-resolved binding on its stack for the
* in-flight request, but the vault must no longer retain or resolve it.
*/
consume(
capability: PresignedTransferCapability,
): BrowserDataResult<true>;
/**
* Best-effort, idempotent retirement for an unused or abandoned identity.
*/
revoke(capability: PresignedTransferCapability): void;
dispose(): void;
}
export function createPresignedCapabilityVault(options: Readonly<{
maxActiveCapabilities: number;
now?: () => number;
}>): PresignedCapabilityVault {
if (
!Number.isSafeInteger(options.maxActiveCapabilities) ||
options.maxActiveCapabilities < 1
) {
throw new TypeError("Presigned capability vault limit is invalid.");
}
const maxActiveCapabilities = options.maxActiveCapabilities;
const now = options.now ?? Date.now;
const byIdentity =
new WeakMap<PresignedTransferCapability, PresignedCapabilityBinding>();
const byReceipt =
new Map<PresignedTransferCapabilityReceipt, PresignedTransferCapability>();
let disposed = false;
function pruneExpired(): void {
const current = now();
if (!Number.isSafeInteger(current)) return;
for (const [receipt, capability] of byReceipt) {
if (capability.expiresAtEpochMs <= current) {
byReceipt.delete(receipt);
byIdentity.delete(capability);
}
}
}
function revoke(capability: PresignedTransferCapability): boolean {
try {
const binding = byIdentity.get(capability);
if (!binding) return false;
byIdentity.delete(capability);
if (byReceipt.get(binding.capabilityReceipt) === capability) {
byReceipt.delete(binding.capabilityReceipt);
}
return true;
} catch {
return false;
}
}
return Object.freeze({
register(
registration: PresignedCapabilityRegistration,
): BrowserDataResult<PresignedTransferCapability> {
if (disposed) {
return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER");
}
pruneExpired();
if (
byReceipt.has(registration.capabilityReceipt) ||
byReceipt.size >= maxActiveCapabilities
) {
return browserDataFailure(
byReceipt.has(registration.capabilityReceipt)
? "CONFLICT"
: "LIMIT_EXCEEDED",
"PRESIGNED_TRANSFER",
byReceipt.has(registration.capabilityReceipt)
? { recovery: "REISSUE_CAPABILITY" }
: undefined,
);
}
const capability = Object.freeze({
capabilityReceipt: registration.capabilityReceipt,
method: registration.method,
binding: freezeBinding(registration.binding),
mediaType: registration.mediaType,
byteLength: registration.byteLength,
maxBytes: registration.maxBytes,
expectedSha256: registration.expectedSha256,
expiresAtEpochMs: registration.expiresAtEpochMs,
}) as PresignedTransferCapability;
const binding: PresignedCapabilityBinding = Object.freeze({
capability,
capabilityReceipt: capability.capabilityReceipt,
method: capability.method,
binding: capability.binding,
href: registration.href,
origin: registration.origin,
path: registration.path,
allowedQueryParameters: Object.freeze([
...registration.allowedQueryParameters,
]),
requestHeaders: freezeHeaders(registration.requestHeaders),
requiredResponseHeaders: freezeHeaders(
registration.requiredResponseHeaders,
),
digestRequestHeader: registration.digestRequestHeader,
digestResponseHeader: registration.digestResponseHeader,
receiptResponseHeader: registration.receiptResponseHeader,
expectedStatus: registration.expectedStatus,
expectedResponseByteLength:
registration.expectedResponseByteLength,
mediaType: capability.mediaType,
byteLength: capability.byteLength,
maxBytes: capability.maxBytes,
expectedSha256: capability.expectedSha256,
expiresAtEpochMs: capability.expiresAtEpochMs,
});
byIdentity.set(capability, binding);
byReceipt.set(capability.capabilityReceipt, capability);
return browserDataSuccess(capability);
},
resolve(
capability: PresignedTransferCapability,
): BrowserDataResult<PresignedCapabilityBinding> {
if (disposed) {
return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER");
}
try {
const binding = byIdentity.get(capability);
return binding
? browserDataSuccess(binding)
: browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
} catch {
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
}
},
consume(
capability: PresignedTransferCapability,
): BrowserDataResult<true> {
if (disposed) {
return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER");
}
return revoke(capability)
? browserDataSuccess(true as const)
: browserDataFailure(
"POLICY_REJECTED",
"PRESIGNED_TRANSFER",
);
},
revoke(capability: PresignedTransferCapability): void {
if (disposed) return;
revoke(capability);
},
dispose() {
if (disposed) return;
disposed = true;
for (const capability of byReceipt.values()) {
byIdentity.delete(capability);
}
byReceipt.clear();
},
});
}
export function createSingleUsePresignedReplayGuard():
PresignedTransferReplayGuard {
const claimed = new WeakSet<PresignedTransferCapability>();
return Object.freeze({
claim(
capability: PresignedTransferCapability,
): BrowserDataResult<true> {
try {
if (claimed.has(capability)) {
return browserDataFailure("CONFLICT", "PRESIGNED_TRANSFER", {
recovery: "REISSUE_CAPABILITY",
});
}
claimed.add(capability);
return browserDataSuccess(true as const);
} catch {
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
}
},
});
}
function freezeBinding(
binding: PresignedTransferBinding,
): PresignedTransferBinding {
return binding.kind === "DOWNLOAD"
? Object.freeze({
kind: "DOWNLOAD" as const,
resourceId: binding.resourceId,
})
: Object.freeze({
kind: "UPLOAD_PART" as const,
protocol: binding.protocol,
sessionId: binding.sessionId,
requestBindingSha256: binding.requestBindingSha256,
uploadBindingSha256: binding.uploadBindingSha256,
partNumber: binding.partNumber,
offset: binding.offset,
idempotencyKey: binding.idempotencyKey,
});
}
function freezeHeaders(
headers: readonly PresignedHeaderBinding[],
): readonly PresignedHeaderBinding[] {
return Object.freeze(
headers.map((header) =>
Object.freeze({ name: header.name, value: header.value }),
),
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
import type {
ResumableUploadCheckpoint,
UploadFileFingerprint,
UploadPartDescriptor,
UploadPartReceipt,
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
export const SAFE_UPLOAD_KEY = /^[A-Za-z0-9][A-Za-z0-9._~:-]{7,127}$/u;
export const SAFE_REGISTRY_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
export const SAFE_OPAQUE_ID = /^[A-Za-z0-9_-]{8,512}$/u;
export const SHA256_HEX = /^[a-f0-9]{64}$/u;
export const RECEIPT_TOKEN =
/^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/u;
export const MEDIA_TYPE =
/^[a-z0-9!#$&^_.+-]{1,63}\/[a-z0-9!#$&^_.+-]{1,63}$/u;
const CHECKPOINT_KEYS = Object.freeze([
"schemaVersion",
"protocol",
"revision",
"state",
"uploadKey",
"requestBindingSha256",
"fingerprint",
"sessionId",
"sessionExpiresAtEpochMs",
"sessionMaxConcurrency",
"acceptedParts",
"updatedAtEpochMs",
] as const);
const FINGERPRINT_KEYS = Object.freeze([
"algorithm",
"digestHex",
"byteLength",
"partSizeBytes",
"partCount",
] as const);
const PART_KEYS = Object.freeze([
"partNumber",
"offset",
"byteLength",
"checksumSha256",
] as const);
const RECEIPT_KEYS = Object.freeze([...PART_KEYS, "receiptToken"] as const);
export function isUploadFileFingerprint(
value: unknown,
): value is UploadFileFingerprint {
if (!exactRecord(value, FINGERPRINT_KEYS)) return false;
return (
value.algorithm === "SHA-256-PARTS-V1" &&
typeof value.digestHex === "string" &&
SHA256_HEX.test(value.digestHex) &&
positiveSafeInteger(value.byteLength) &&
positiveSafeInteger(value.partSizeBytes) &&
positiveSafeInteger(value.partCount) &&
Math.ceil(value.byteLength / value.partSizeBytes) ===
value.partCount
);
}
export function isUploadPartDescriptor(
value: unknown,
): value is UploadPartDescriptor {
if (!exactRecord(value, PART_KEYS)) return false;
return (
positiveSafeInteger(value.partNumber) &&
nonNegativeSafeInteger(value.offset) &&
positiveSafeInteger(value.byteLength) &&
typeof value.checksumSha256 === "string" &&
SHA256_HEX.test(value.checksumSha256)
);
}
export function isUploadPartReceipt(
value: unknown,
): value is UploadPartReceipt {
return (
exactRecord(value, RECEIPT_KEYS) &&
isUploadPartDescriptor({
partNumber: value.partNumber,
offset: value.offset,
byteLength: value.byteLength,
checksumSha256: value.checksumSha256,
}) &&
typeof value.receiptToken === "string" &&
isSafeUploadReceiptToken(value.receiptToken)
);
}
export function isSafeUploadReceiptToken(value: string): boolean {
return RECEIPT_TOKEN.test(value) && !value.includes("://");
}
export function isResumableUploadCheckpoint(
value: unknown,
): value is ResumableUploadCheckpoint {
if (!exactRecord(value, CHECKPOINT_KEYS)) return false;
if (
value.schemaVersion !== 1 ||
value.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
!positiveSafeInteger(value.revision) ||
(value.state !== "ACTIVE" && value.state !== "ABORT_PENDING") ||
typeof value.uploadKey !== "string" ||
!SAFE_UPLOAD_KEY.test(value.uploadKey) ||
typeof value.requestBindingSha256 !== "string" ||
!SHA256_HEX.test(value.requestBindingSha256) ||
!isUploadFileFingerprint(value.fingerprint) ||
typeof value.sessionId !== "string" ||
!SAFE_OPAQUE_ID.test(value.sessionId) ||
!positiveSafeInteger(value.sessionExpiresAtEpochMs) ||
!positiveSafeInteger(value.sessionMaxConcurrency) ||
!Array.isArray(value.acceptedParts) ||
value.acceptedParts.length > value.fingerprint.partCount ||
!nonNegativeSafeInteger(value.updatedAtEpochMs)
) {
return false;
}
let previousPartNumber = 0;
for (const part of value.acceptedParts) {
if (
!isUploadPartReceipt(part) ||
part.partNumber <= previousPartNumber ||
!partMatchesFingerprint(part, value.fingerprint)
) {
return false;
}
previousPartNumber = part.partNumber;
}
return true;
}
export function partMatchesFingerprint(
part: UploadPartDescriptor,
fingerprint: UploadFileFingerprint,
): boolean {
if (
part.partNumber < 1 ||
part.partNumber > fingerprint.partCount ||
part.offset !== (part.partNumber - 1) * fingerprint.partSizeBytes
) {
return false;
}
const remaining = fingerprint.byteLength - part.offset;
return (
remaining > 0 &&
part.byteLength === Math.min(fingerprint.partSizeBytes, remaining)
);
}
export function samePart(
left: UploadPartDescriptor,
right: UploadPartDescriptor,
): boolean {
return (
left.partNumber === right.partNumber &&
left.offset === right.offset &&
left.byteLength === right.byteLength &&
left.checksumSha256 === right.checksumSha256
);
}
function exactRecord<const Keys extends readonly string[]>(
value: unknown,
keys: Keys,
): value is Record<Keys[number], unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return (
actual.length === expected.length &&
actual.every((key, index) => key === expected[index])
);
}
function positiveSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) > 0;
}
function nonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
@@ -0,0 +1,729 @@
import type {
UploadProviderFailure,
UploadProviderResult,
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
import type {
BrowserDataFailureCode,
BrowserDataRecovery,
} from "../../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import type {
ResumableUploadControlOperation,
ResumableUploadJsonTransport,
} from "./http-control-plane-adapter.ts";
export type ResumableUploadEndpointMap = Readonly<
Record<ResumableUploadControlOperation, string>
>;
export type ResumableUploadFetchTransportDependencies = Readonly<{
endpoints: ResumableUploadEndpointMap;
allowedOrigins: readonly string[];
credentials: "include" | "same-origin";
fetcher?: typeof fetch;
requestHeaders?: readonly Readonly<{ name: string; value: string }>[];
timeoutMs?: number;
maxRequestBytes?: number;
maxResponseBytes?: number;
maxRetryAfterMs?: number;
expectedSuccessStatuses?: Partial<
Readonly<Record<ResumableUploadControlOperation, number>>
>;
}>;
const DEFAULT_SUCCESS_STATUSES: Readonly<
Record<ResumableUploadControlOperation, number>
> = Object.freeze({
CREATE_SESSION: 201,
GET_STATUS: 200,
COMPLETE: 200,
ABORT: 200,
});
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024;
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_RETRY_AFTER_MS = 30_000;
const ABSOLUTE_MAX_JSON_BYTES = 4 * 1024 * 1024;
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
const OPERATIONS = Object.freeze(
Object.keys(
DEFAULT_SUCCESS_STATUSES,
) as ResumableUploadControlOperation[],
);
const OPERATION_SET: ReadonlySet<string> = new Set(OPERATIONS);
export function createResumableUploadFetchJsonTransport(
input: ResumableUploadFetchTransportDependencies,
): ResumableUploadJsonTransport {
const endpoints = snapshotEndpoints(input.endpoints, input.allowedOrigins);
const fetcher =
input.fetcher ?? globalThis.fetch?.bind(globalThis);
if (
typeof fetcher !== "function" ||
!["include", "same-origin"].includes(input.credentials)
) {
throw new TypeError("Upload fetch transport dependency is invalid.");
}
const headers = snapshotHeaders(input.requestHeaders ?? []);
const timeoutMs = boundedPositiveInteger(
input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1,
120_000,
"timeout",
);
const maxRequestBytes = boundedPositiveInteger(
input.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES,
1,
ABSOLUTE_MAX_JSON_BYTES,
"request bytes",
);
const maxResponseBytes = boundedPositiveInteger(
input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
1,
ABSOLUTE_MAX_JSON_BYTES,
"response bytes",
);
const maxRetryAfterMs = boundedPositiveInteger(
input.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS,
1,
60_000,
"Retry-After",
);
const statuses = snapshotStatuses(input.expectedSuccessStatuses);
const transport: ResumableUploadJsonTransport = {
async execute(request) {
const snapshot = snapshotTransportRequest(request);
if (!snapshot) {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_SESSION",
);
}
const { operation, body: requestBody, signal } = snapshot;
const endpoint = endpoints[operation];
let body: string;
try {
body = JSON.stringify(requestBody);
} catch {
return failure(
"INVALID_INPUT",
operation,
false,
"NONE",
);
}
if (typeof body !== "string") {
return failure("INVALID_INPUT", operation, false, "NONE");
}
const requestBytes = new TextEncoder().encode(body).byteLength;
if (
requestBytes < 2 ||
requestBytes > maxRequestBytes ||
signal.aborted
) {
return signal.aborted
? failure(
"ABORTED",
operation,
false,
"NONE",
)
: failure(
"LIMIT_EXCEEDED",
operation,
false,
"NONE",
);
}
const attempt = createFetchAttempt(signal, timeoutMs);
try {
const fetchPromise = fetcher(endpoint, {
method: "POST",
headers: headersFor(headers),
body,
signal: attempt.signal,
credentials: input.credentials,
redirect: "error",
referrerPolicy: "no-referrer",
cache: "no-store",
mode: new URL(endpoint).origin === globalThis.location?.origin
? "same-origin"
: "cors",
});
const raced = await Promise.race([
fetchPromise.then(
(value) => {
if (attempt.terminalKind()) {
cancelResponseBody(value);
}
return { kind: "RESPONSE" as const, value };
},
() => ({ kind: "FAILED" as const }),
),
attempt.terminal,
]);
if (raced.kind !== "RESPONSE") {
return attemptFailure(attempt, operation);
}
const response = raced.value;
if (
response.redirected ||
response.type === "opaqueredirect" ||
!sameUrl(response.url, endpoint)
) {
cancelResponseBody(response);
return failure(
"POLICY_REJECTED",
operation,
false,
"NONE",
);
}
if (response.status !== statuses[operation]) {
const failed = statusFailure(
response,
operation,
maxRetryAfterMs,
);
cancelResponseBody(response);
return failed;
}
if (!jsonContentType(response.headers.get("content-type"))) {
cancelResponseBody(response);
return failure(
"CORRUPT_DATA",
operation,
false,
"RECONCILE",
);
}
const decoded = await readBoundedJson(
response,
maxResponseBytes,
operation,
attempt,
);
return decoded.ok
? browserDataSuccess(decoded.value)
: decoded;
} catch {
return attemptFailure(attempt, operation);
} finally {
attempt.release();
}
},
};
return Object.freeze(transport);
}
function snapshotEndpoints(
value: ResumableUploadEndpointMap,
allowedOriginValues: readonly string[],
): ResumableUploadEndpointMap {
if (
!value ||
typeof value !== "object" ||
!Array.isArray(allowedOriginValues) ||
allowedOriginValues.length < 1
) {
throw new TypeError("Upload endpoints are invalid.");
}
const allowedOrigins = new Set(
allowedOriginValues.map((origin) => {
const parsed = new URL(origin);
if (parsed.origin !== parsed.href.replace(/\/$/u, "")) {
throw new TypeError("Allowed upload origin is invalid.");
}
return parsed.origin;
}),
);
const snapshot = Object.create(null) as Record<
ResumableUploadControlOperation,
string
>;
for (const operation of OPERATIONS) {
const endpoint = value[operation];
const parsed = new URL(endpoint);
if (
parsed.protocol !== "https:" ||
!allowedOrigins.has(parsed.origin) ||
parsed.username ||
parsed.password ||
parsed.hash ||
parsed.search
) {
throw new TypeError("Upload endpoint is outside policy.");
}
snapshot[operation] = parsed.href;
}
if (Object.keys(value).length !== 4) {
throw new TypeError("Upload endpoint map is invalid.");
}
return Object.freeze(snapshot);
}
function snapshotHeaders(
input: readonly Readonly<{ name: string; value: string }>[],
): readonly Readonly<{ name: string; value: string }>[] {
const seen = new Set<string>();
const forbidden = new Set([
"accept",
"authorization",
"connection",
"content-type",
"content-length",
"cookie",
"host",
"origin",
"proxy-authorization",
"referer",
"set-cookie",
"transfer-encoding",
]);
return Object.freeze(
input.map((header) => {
const name = header.name.toLowerCase();
if (
!HEADER_NAME.test(name) ||
forbidden.has(name) ||
seen.has(name) ||
typeof header.value !== "string" ||
header.value.length > 2048 ||
hasForbiddenHeaderValueCharacter(header.value)
) {
throw new TypeError("Upload request header is invalid.");
}
seen.add(name);
return Object.freeze({ name, value: header.value });
}),
);
}
function snapshotStatuses(
overrides:
| Partial<
Readonly<Record<ResumableUploadControlOperation, number>>
>
| undefined,
): Readonly<Record<ResumableUploadControlOperation, number>> {
if (
overrides !== undefined &&
(!isPlainRecord(overrides) ||
Object.keys(overrides).some(
(operation) => !isControlOperation(operation),
))
) {
throw new TypeError("Upload success status policy is invalid.");
}
const statuses = Object.freeze(
Object.assign(
Object.create(null) as Record<
ResumableUploadControlOperation,
number
>,
DEFAULT_SUCCESS_STATUSES,
overrides,
),
);
if (
Object.values(statuses).some(
(status) =>
!Number.isSafeInteger(status) || status < 200 || status > 299,
)
) {
throw new TypeError("Upload success status policy is invalid.");
}
return statuses;
}
function headersFor(
configured: readonly Readonly<{ name: string; value: string }>[],
): Headers {
const headers = new Headers({
accept: "application/json",
"content-type": "application/json; charset=utf-8",
});
for (const header of configured) {
headers.set(header.name, header.value);
}
return headers;
}
async function readBoundedJson(
response: Response,
maxBytes: number,
operation: ResumableUploadControlOperation,
attempt: FetchAttempt,
): Promise<UploadProviderResult<unknown>> {
const contentLength = response.headers.get("content-length");
let declaredLength: number | null = null;
if (
contentLength &&
(!/^(0|[1-9][0-9]*)$/u.test(contentLength) ||
Number(contentLength) > maxBytes)
) {
cancelResponseBody(response);
return failure(
"LIMIT_EXCEEDED",
operation,
false,
"RECONCILE",
);
}
if (contentLength !== null) {
declaredLength = Number(contentLength);
}
const reader = response.body?.getReader();
if (!reader) {
return failure(
"CORRUPT_DATA",
operation,
false,
"RECONCILE",
);
}
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const raced = await Promise.race([
reader.read().then(
(value) => ({ kind: "READ" as const, value }),
() => ({ kind: "FAILED" as const }),
),
attempt.terminal,
]);
if (raced.kind === "ABORT" || raced.kind === "TIMEOUT") {
cancelReader(reader);
return attemptFailure(attempt, operation);
}
if (raced.kind === "FAILED") {
cancelReader(reader);
return attemptFailure(attempt, operation);
}
const next = raced.value;
if (next.done) break;
if (!(next.value instanceof Uint8Array)) {
cancelReader(reader);
return failure(
"CORRUPT_DATA",
operation,
false,
"RECONCILE",
);
}
total += next.value.byteLength;
if (total > maxBytes) {
cancelReader(reader);
return failure(
"LIMIT_EXCEEDED",
operation,
false,
"RECONCILE",
);
}
chunks.push(Uint8Array.from(next.value));
}
} catch {
cancelReader(reader);
return attemptFailure(attempt, operation);
} finally {
releaseReader(reader);
}
if (declaredLength !== null && declaredLength !== total) {
return failure(
"INTEGRITY_FAILED",
operation,
false,
"RECONCILE",
);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
const value: unknown = JSON.parse(text);
return value && typeof value === "object" && !Array.isArray(value)
? browserDataSuccess(value)
: failure(
"CORRUPT_DATA",
operation,
false,
"RECONCILE",
);
} catch {
return failure(
"CORRUPT_DATA",
operation,
false,
"RECONCILE",
);
}
}
function statusFailure(
response: Response,
operation: ResumableUploadControlOperation,
maxRetryAfterMs: number,
): UploadProviderResult<never> {
if (response.status === 400 || response.status === 422) {
return failure("INVALID_INPUT", operation, false, "NONE");
}
if (response.status === 401 || response.status === 403) {
return failure("PERMISSION_DENIED", operation, false, "NONE");
}
if (response.status === 404) {
return failure("NOT_FOUND", operation, false, "RECONCILE");
}
if (response.status === 409 || response.status === 412) {
return failure("CONFLICT", operation, false, "RECONCILE");
}
if (response.status === 410) {
return failure("EXPIRED_RESOURCE", operation, false, "RESTART");
}
if (response.status === 413) {
return failure("LIMIT_EXCEEDED", operation, false, "NONE");
}
if (response.status === 429) {
const retryAfterMs = parseRetryAfter(
response.headers.get("retry-after"),
);
return retryAfterMs !== null && retryAfterMs <= maxRetryAfterMs
? failure(
"UNAVAILABLE",
operation,
true,
"RESUME",
retryAfterMs,
)
: failure("UNAVAILABLE", operation, false, "RESUME");
}
return response.status >= 500 && response.status <= 599
? failure("UNAVAILABLE", operation, true, "RESUME")
: failure("UNAVAILABLE", operation, false, "RESUME");
}
function failure(
code: BrowserDataFailureCode,
operation: ResumableUploadControlOperation,
retryable: boolean,
recovery: BrowserDataRecovery,
retryAfterMs?: number,
): UploadProviderResult<never> {
const operationMap = {
CREATE_SESSION: "UPLOAD_SESSION",
GET_STATUS: "UPLOAD_RECONCILE",
COMPLETE: "UPLOAD_COMPLETE",
ABORT: "UPLOAD_ABORT",
} as const;
const error: UploadProviderFailure = Object.freeze({
code,
operation: operationMap[operation],
retryable,
recovery,
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
});
return Object.freeze({ ok: false, error });
}
type FetchAttemptTerminal =
| Readonly<{ kind: "ABORT" }>
| Readonly<{ kind: "TIMEOUT" }>;
type FetchAttempt = Readonly<{
signal: AbortSignal;
terminal: Promise<FetchAttemptTerminal>;
terminalKind(): FetchAttemptTerminal["kind"] | null;
release(): void;
}>;
function createFetchAttempt(
parent: AbortSignal,
timeoutMs: number,
): FetchAttempt {
const controller = new AbortController();
let terminalKind: FetchAttemptTerminal["kind"] | null = null;
let resolveTerminal:
| ((value: FetchAttemptTerminal) => void)
| undefined;
const terminal = new Promise<FetchAttemptTerminal>(
(resolve) => {
resolveTerminal = resolve;
},
);
const finish = (kind: FetchAttemptTerminal["kind"]) => {
if (terminalKind) return;
terminalKind = kind;
controller.abort();
resolveTerminal?.(Object.freeze({ kind }));
};
const abort = () => finish("ABORT");
parent.addEventListener("abort", abort, { once: true });
if (parent.aborted) abort();
const timer = setTimeout(() => {
finish("TIMEOUT");
}, timeoutMs);
return Object.freeze({
signal: controller.signal,
terminal,
terminalKind: () => terminalKind,
release() {
clearTimeout(timer);
parent.removeEventListener("abort", abort);
},
});
}
function attemptFailure(
attempt: FetchAttempt,
operation: ResumableUploadControlOperation,
): UploadProviderResult<never> {
return attempt.terminalKind() === "ABORT"
? failure("ABORTED", operation, false, "NONE")
: failure("UNAVAILABLE", operation, true, "RESUME");
}
function cancelResponseBody(response: Response): void {
try {
void response.body?.cancel().catch(() => {
// Response cancellation is best effort after closed classification.
});
} catch {
// A cancellation failure cannot change the already classified result.
}
}
function cancelReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
): void {
try {
void reader.cancel().catch(() => {
// Reader cancellation is best effort after closed classification.
});
} catch {
// A cancellation failure cannot change the already classified result.
}
}
function releaseReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
): void {
try {
reader.releaseLock();
} catch {
// A pending native read may keep the lock until cancellation settles.
}
}
function parseRetryAfter(value: string | null): number | null {
if (!value) return null;
if (/^(0|[1-9][0-9]*)$/u.test(value)) {
const seconds = Number(value);
const milliseconds = seconds * 1000;
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp)
? Math.max(0, timestamp - Date.now())
: null;
}
function jsonContentType(value: string | null): boolean {
return Boolean(
value &&
/^application\/json(?:;\s*charset=utf-8)?$/iu.test(value.trim()),
);
}
function sameUrl(actual: string, expected: string): boolean {
try {
return new URL(actual).href === new URL(expected).href;
} catch {
return false;
}
}
function boundedPositiveInteger(
value: number,
minimum: number,
maximum: number,
label: string,
): number {
if (
!Number.isSafeInteger(value) ||
value < minimum ||
value > maximum
) {
throw new TypeError(`Upload ${label} policy is invalid.`);
}
return value;
}
function isAbortSignal(value: unknown): value is AbortSignal {
return Boolean(
value &&
typeof value === "object" &&
typeof (value as AbortSignal).aborted === "boolean" &&
typeof (value as AbortSignal).addEventListener === "function",
);
}
function isControlOperation(
value: unknown,
): value is ResumableUploadControlOperation {
return typeof value === "string" && OPERATION_SET.has(value);
}
function snapshotTransportRequest(
value: unknown,
): Readonly<{
operation: ResumableUploadControlOperation;
body: Readonly<Record<string, unknown>>;
signal: AbortSignal;
}> | null {
try {
if (!value || typeof value !== "object") return null;
const record = value as Readonly<Record<string, unknown>>;
return isControlOperation(record.operation) &&
isPlainRecord(record.body) &&
isAbortSignal(record.signal)
? Object.freeze({
operation: record.operation,
body: record.body,
signal: record.signal,
})
: null;
} catch {
return null;
}
}
function hasForbiddenHeaderValueCharacter(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0);
return codePoint !== undefined && (codePoint <= 31 || codePoint === 127);
});
}
function isPlainRecord(
value: unknown,
): value is Readonly<Record<string, unknown>> {
if (!value || typeof value !== "object") {
return false;
}
try {
if (Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
} catch {
return false;
}
}
@@ -0,0 +1,612 @@
import type {
PresignedUploadPartCapability,
PresignedUploadPartCapabilityProvider,
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
import type {
ResumableUploadControlPlane,
UploadFileFingerprint,
UploadPartReceipt,
UploadProviderResult,
UploadSession,
UploadSessionStatus,
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import {
isSafeUploadReceiptToken,
isUploadFileFingerprint,
isUploadPartReceipt,
MEDIA_TYPE,
SAFE_OPAQUE_ID,
SAFE_REGISTRY_ID,
SAFE_UPLOAD_KEY,
SHA256_HEX,
} from "./checkpoint-schema.ts";
export type ResumableUploadControlOperation =
| "CREATE_SESSION"
| "GET_STATUS"
| "COMPLETE"
| "ABORT";
/**
* Composition-owned transport. Endpoint URLs, auth headers and raw response
* parsing stay behind this seam. `operation` is a closed endpoint identifier,
* never a caller-provided URL.
*/
export interface ResumableUploadJsonTransport {
execute(input: Readonly<{
operation: ResumableUploadControlOperation;
body: Readonly<Record<string, unknown>>;
signal: AbortSignal;
}>): Promise<UploadProviderResult<unknown>>;
}
export type ResumableUploadHttpControlPlaneDependencies = Readonly<{
transport: ResumableUploadJsonTransport;
partCapabilities: PresignedUploadPartCapabilityProvider;
}>;
const MAX_PART_COUNT = 10_000;
const MAX_RECEIPT_COUNT = 10_000;
export function createResumableUploadHttpControlPlane(
dependencies: ResumableUploadHttpControlPlaneDependencies,
): ResumableUploadControlPlane<PresignedUploadPartCapability> {
const execute = dependencies.transport?.execute;
const issueUploadPart =
dependencies.partCapabilities?.issueUploadPart;
if (
typeof execute !== "function" ||
typeof issueUploadPart !== "function"
) {
throw new TypeError("Upload HTTP control-plane dependency is invalid.");
}
const controlPlane: ResumableUploadControlPlane<PresignedUploadPartCapability> =
{
async createSession(input) {
if (
input.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
!SAFE_UPLOAD_KEY.test(input.uploadKey) ||
!SAFE_REGISTRY_ID.test(input.purpose) ||
!MEDIA_TYPE.test(input.mediaType) ||
!SHA256_HEX.test(input.requestBindingSha256) ||
!isUploadFileFingerprint(input.fingerprint) ||
!positiveSafeInteger(input.requestedPartSizeBytes) ||
!positiveSafeInteger(input.requestedMaxConcurrency) ||
!safeIdempotencyKey(input.idempotencyKey)
) {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_SESSION",
);
}
const response = await invokeJsonTransport(
execute,
dependencies.transport,
"CREATE_SESSION",
Object.freeze({
protocol: input.protocol,
uploadKey: input.uploadKey,
purpose: input.purpose,
mediaType: input.mediaType,
requestBindingSha256: input.requestBindingSha256,
fingerprint: snapshotFingerprint(input.fingerprint),
requestedPartSizeBytes: input.requestedPartSizeBytes,
requestedMaxConcurrency: input.requestedMaxConcurrency,
idempotencyKey: input.idempotencyKey,
}),
input.signal,
"UPLOAD_SESSION",
);
if (!response.ok) return response;
const session = decodeSession(response.value);
return session
? browserDataSuccess(session)
: browserDataFailure(
"CORRUPT_DATA",
"UPLOAD_SESSION",
{ recovery: "RECONCILE" },
);
},
async getStatus(input) {
if (
input.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
!SAFE_OPAQUE_ID.test(input.sessionId) ||
!SHA256_HEX.test(input.requestBindingSha256) ||
!isUploadFileFingerprint(input.fingerprint)
) {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_RECONCILE",
);
}
const response = await invokeJsonTransport(
execute,
dependencies.transport,
"GET_STATUS",
Object.freeze({
protocol: input.protocol,
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
fingerprint: snapshotFingerprint(input.fingerprint),
}),
input.signal,
"UPLOAD_RECONCILE",
);
if (!response.ok) return response;
const status = decodeStatus(response.value);
return status
? browserDataSuccess(status)
: browserDataFailure(
"CORRUPT_DATA",
"UPLOAD_RECONCILE",
{ recovery: "RECONCILE" },
);
},
async issuePartCapability(input) {
if (
input.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
!SAFE_OPAQUE_ID.test(input.sessionId) ||
!SHA256_HEX.test(input.requestBindingSha256) ||
!SHA256_HEX.test(input.uploadBindingSha256) ||
!isUploadFileFingerprint(input.fingerprint) ||
!MEDIA_TYPE.test(input.mediaType) ||
!isUploadPartReceiptShape(input.part) ||
!safeIdempotencyKey(input.idempotencyKey)
) {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_PART",
);
}
let issued;
try {
issued = await issueUploadPart.call(
dependencies.partCapabilities,
{
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
uploadBindingSha256: input.uploadBindingSha256,
partNumber: input.part.partNumber,
offset: input.part.offset,
byteLength: input.part.byteLength,
checksumSha256: input.part.checksumSha256,
mediaType: input.mediaType,
idempotencyKey: input.idempotencyKey,
signal: input.signal,
},
);
} catch {
return browserDataFailure(
"UNAVAILABLE",
"UPLOAD_PART",
{ retryable: true, recovery: "REISSUE_CAPABILITY" },
);
}
if (!issued.ok) {
return browserDataFailure(
issued.error.code,
"UPLOAD_PART",
{
retryable: issued.error.retryable,
recovery: issued.error.recovery,
},
);
}
const capability = issued.value;
if (
capability.method !== "PUT" ||
capability.binding.kind !== "UPLOAD_PART" ||
capability.binding.protocol !== input.protocol ||
capability.binding.sessionId !== input.sessionId ||
capability.binding.requestBindingSha256 !==
input.requestBindingSha256 ||
capability.binding.uploadBindingSha256 !==
input.uploadBindingSha256 ||
capability.binding.partNumber !== input.part.partNumber ||
capability.binding.offset !== input.part.offset ||
capability.binding.idempotencyKey !== input.idempotencyKey ||
capability.mediaType !== input.mediaType ||
capability.byteLength !== input.part.byteLength ||
capability.maxBytes !== input.part.byteLength ||
capability.expectedSha256 !== input.part.checksumSha256 ||
!positiveSafeInteger(capability.expiresAtEpochMs)
) {
return browserDataFailure(
"POLICY_REJECTED",
"UPLOAD_PART",
{ recovery: "REISSUE_CAPABILITY" },
);
}
return browserDataSuccess(
Object.freeze({
capability,
uploadBindingSha256: input.uploadBindingSha256,
expiresAtEpochMs: capability.expiresAtEpochMs,
}),
);
},
async complete(input) {
if (
input.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
!SAFE_OPAQUE_ID.test(input.sessionId) ||
!SHA256_HEX.test(input.requestBindingSha256) ||
!isUploadFileFingerprint(input.fingerprint) ||
!safeIdempotencyKey(input.idempotencyKey) ||
!orderedReceipts(
input.orderedParts,
input.fingerprint,
true,
)
) {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_COMPLETE",
);
}
const response = await invokeJsonTransport(
execute,
dependencies.transport,
"COMPLETE",
Object.freeze({
protocol: input.protocol,
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
fingerprint: snapshotFingerprint(input.fingerprint),
orderedParts: Object.freeze(
input.orderedParts.map(snapshotReceipt),
),
idempotencyKey: input.idempotencyKey,
}),
input.signal,
"UPLOAD_COMPLETE",
);
if (!response.ok) return response;
const completed = decodeCompletion(response.value);
return completed
? browserDataSuccess(completed)
: browserDataFailure(
"CORRUPT_DATA",
"UPLOAD_COMPLETE",
{ recovery: "RECONCILE" },
);
},
async abort(input) {
if (
input.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
!SAFE_OPAQUE_ID.test(input.sessionId) ||
!SHA256_HEX.test(input.requestBindingSha256) ||
!safeIdempotencyKey(input.idempotencyKey)
) {
return browserDataFailure("INVALID_INPUT", "UPLOAD_ABORT");
}
const response = await invokeJsonTransport(
execute,
dependencies.transport,
"ABORT",
Object.freeze({
protocol: input.protocol,
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
idempotencyKey: input.idempotencyKey,
}),
input.signal,
"UPLOAD_ABORT",
);
if (!response.ok) return response;
if (
!exactKeys(response.value, ["state"]) ||
typeof response.value.state !== "string" ||
![
"ABORTED",
"NOT_FOUND",
"EXPIRED",
"ALREADY_COMPLETED",
].includes(response.value.state)
) {
return browserDataFailure(
"CORRUPT_DATA",
"UPLOAD_ABORT",
{ recovery: "RECONCILE" },
);
}
return browserDataSuccess(
Object.freeze({
state: response.value.state as
| "ABORTED"
| "NOT_FOUND"
| "EXPIRED"
| "ALREADY_COMPLETED",
}),
);
},
};
return Object.freeze(controlPlane);
}
async function invokeJsonTransport(
execute: ResumableUploadJsonTransport["execute"],
owner: ResumableUploadJsonTransport,
operation: ResumableUploadControlOperation,
body: Readonly<Record<string, unknown>>,
signal: AbortSignal,
failureOperation:
| "UPLOAD_SESSION"
| "UPLOAD_RECONCILE"
| "UPLOAD_COMPLETE"
| "UPLOAD_ABORT",
): Promise<UploadProviderResult<unknown>> {
try {
const response = await execute.call(owner, {
operation,
body,
signal,
});
if (!response || typeof response !== "object") {
return browserDataFailure("UNAVAILABLE", failureOperation, {
retryable: true,
recovery: "RESUME",
});
}
return response;
} catch {
return browserDataFailure("UNAVAILABLE", failureOperation, {
retryable: true,
recovery: "RESUME",
});
}
}
function decodeSession(value: unknown): UploadSession | null {
if (
!exactKeys(value, [
"protocol",
"sessionId",
"requestBindingSha256",
"fingerprint",
"partSizeBytes",
"partCount",
"maxConcurrency",
"expiresAtEpochMs",
]) ||
value.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
typeof value.sessionId !== "string" ||
!SAFE_OPAQUE_ID.test(value.sessionId) ||
typeof value.requestBindingSha256 !== "string" ||
!SHA256_HEX.test(value.requestBindingSha256) ||
!isUploadFileFingerprint(value.fingerprint) ||
!positiveSafeInteger(value.partSizeBytes) ||
value.partSizeBytes !== value.fingerprint.partSizeBytes ||
!positiveSafeInteger(value.partCount) ||
value.partCount !== value.fingerprint.partCount ||
value.partCount > MAX_PART_COUNT ||
!positiveSafeInteger(value.maxConcurrency) ||
!positiveSafeInteger(value.expiresAtEpochMs)
) {
return null;
}
return Object.freeze({
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: value.sessionId,
requestBindingSha256: value.requestBindingSha256,
fingerprint: snapshotFingerprint(value.fingerprint),
partSizeBytes: value.partSizeBytes,
partCount: value.partCount,
maxConcurrency: value.maxConcurrency,
expiresAtEpochMs: value.expiresAtEpochMs,
});
}
function decodeStatus(value: unknown): UploadSessionStatus | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (
record.state === "ACTIVE" &&
exactKeys(record, ["state", "session", "acceptedParts"])
) {
const session = decodeSession(record.session);
if (
!session ||
!Array.isArray(record.acceptedParts) ||
record.acceptedParts.length > MAX_RECEIPT_COUNT ||
!record.acceptedParts.every(isUploadPartReceipt)
) {
return null;
}
const parts = Object.freeze(
record.acceptedParts.map(snapshotReceipt),
);
return orderedReceipts(parts, session.fingerprint, false)
? Object.freeze({
state: "ACTIVE",
session,
acceptedParts: parts,
})
: null;
}
if (
record.state === "QUARANTINED" &&
exactKeys(record, ["state", "session", "resourceId"])
) {
const session = decodeSession(record.session);
return session &&
typeof record.resourceId === "string" &&
SAFE_OPAQUE_ID.test(record.resourceId)
? Object.freeze({
state: "QUARANTINED",
session,
resourceId: record.resourceId,
})
: null;
}
if (
typeof record.state === "string" &&
["ABORTED", "EXPIRED", "NOT_FOUND"].includes(record.state) &&
exactKeys(record, [
"state",
"protocol",
"sessionId",
"requestBindingSha256",
]) &&
record.protocol === RESUMABLE_UPLOAD_PROTOCOL &&
typeof record.sessionId === "string" &&
SAFE_OPAQUE_ID.test(record.sessionId) &&
typeof record.requestBindingSha256 === "string" &&
SHA256_HEX.test(record.requestBindingSha256)
) {
return Object.freeze({
state: record.state as "ABORTED" | "EXPIRED" | "NOT_FOUND",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: record.sessionId,
requestBindingSha256: record.requestBindingSha256,
});
}
return null;
}
function decodeCompletion(
value: unknown,
): Awaited<
ReturnType<
ResumableUploadControlPlane<PresignedUploadPartCapability>["complete"]
>
> extends UploadProviderResult<infer Outcome>
? Outcome | null
: never {
if (
!exactKeys(value, [
"state",
"protocol",
"sessionId",
"requestBindingSha256",
"fingerprint",
"resourceId",
]) ||
value.state !== "QUARANTINED" ||
value.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
typeof value.sessionId !== "string" ||
!SAFE_OPAQUE_ID.test(value.sessionId) ||
typeof value.requestBindingSha256 !== "string" ||
!SHA256_HEX.test(value.requestBindingSha256) ||
!isUploadFileFingerprint(value.fingerprint) ||
typeof value.resourceId !== "string" ||
!SAFE_OPAQUE_ID.test(value.resourceId)
) {
return null;
}
return Object.freeze({
state: "QUARANTINED",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: value.sessionId,
requestBindingSha256: value.requestBindingSha256,
fingerprint: snapshotFingerprint(value.fingerprint),
resourceId: value.resourceId,
});
}
function orderedReceipts(
parts: readonly UploadPartReceipt[],
fingerprint: UploadFileFingerprint,
requireComplete: boolean,
): boolean {
if (
parts.length > fingerprint.partCount ||
parts.length > MAX_RECEIPT_COUNT ||
(requireComplete && parts.length !== fingerprint.partCount)
) {
return false;
}
let previousPartNumber = 0;
return parts.every((part) => {
const valid =
isUploadPartReceipt(part) &&
isSafeUploadReceiptToken(part.receiptToken) &&
part.partNumber > previousPartNumber &&
part.partNumber <= fingerprint.partCount &&
part.offset ===
(part.partNumber - 1) * fingerprint.partSizeBytes &&
part.byteLength ===
Math.min(
fingerprint.partSizeBytes,
fingerprint.byteLength - part.offset,
);
previousPartNumber = part.partNumber;
return valid;
});
}
function isUploadPartReceiptShape(
value: unknown,
): value is Readonly<{
partNumber: number;
offset: number;
byteLength: number;
checksumSha256: string;
}> {
return (
exactKeys(value, [
"partNumber",
"offset",
"byteLength",
"checksumSha256",
]) &&
positiveSafeInteger(value.partNumber) &&
nonNegativeSafeInteger(value.offset) &&
positiveSafeInteger(value.byteLength) &&
typeof value.checksumSha256 === "string" &&
SHA256_HEX.test(value.checksumSha256)
);
}
function snapshotFingerprint(
value: UploadFileFingerprint,
): UploadFileFingerprint {
return Object.freeze({ ...value });
}
function snapshotReceipt(value: UploadPartReceipt): UploadPartReceipt {
return Object.freeze({ ...value });
}
function exactKeys(
value: unknown,
keys: readonly string[],
): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return (
actual.length === expected.length &&
actual.every((key, index) => key === expected[index])
);
}
function safeIdempotencyKey(value: string): boolean {
return (
typeof value === "string" &&
value.length >= 16 &&
value.length <= 160 &&
/^[A-Za-z0-9._~-]+$/u.test(value)
);
}
function positiveSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) > 0;
}
function nonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
@@ -0,0 +1,40 @@
export {
createResumableUploadFetchJsonTransport,
type ResumableUploadEndpointMap,
type ResumableUploadFetchTransportDependencies,
} from "./fetch-json-transport.ts";
export {
createResumableUploadHttpControlPlane,
type ResumableUploadControlOperation,
type ResumableUploadHttpControlPlaneDependencies,
type ResumableUploadJsonTransport,
} from "./http-control-plane-adapter.ts";
export {
createIndexedDbResumableUploadCheckpointRuntime,
createIndexedDbResumableUploadCheckpointStore,
uploadCheckpointDatabaseName,
type IndexedDbUploadCheckpointDependencies,
type IndexedDbUploadCheckpointRuntime,
type IndexedDbUploadCheckpointScope,
} from "./indexeddb-checkpoint-store.ts";
export { createPresignedUploadPartExecutor } from "./presigned-upload-part-executor.ts";
export {
createResumableUploadRuntime,
type ResumableUploadRuntime,
type ResumableUploadRuntimeDependencies,
} from "./resumable-upload-runtime.ts";
export {
resolveResumableUploadRuntimePolicy,
type ResumableUploadRuntimePolicy,
} from "./runtime-policy.ts";
export {
createBrowserUploadCancellationChannel,
type BrowserUploadCancellationDependencies,
type UploadCancellationBroadcastFacade,
type UploadCancellationChannel,
type UploadCancellationListener,
} from "./upload-cancellation-channel.ts";
export {
createResumableUploadWebLock,
type UploadMutationLock,
} from "./upload-mutation-lock.ts";
@@ -0,0 +1,667 @@
import type {
ResumableUploadCheckpointAdmin,
ResumableUploadCheckpoint,
ResumableUploadCheckpointStore,
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
mapBrowserDataException,
} from "../../browser-file-storage/result.ts";
import {
isResumableUploadCheckpoint,
SAFE_OPAQUE_ID,
SAFE_UPLOAD_KEY,
} from "./checkpoint-schema.ts";
const DATABASE_VERSION = 1;
const CHECKPOINT_STORE = "checkpoints";
const GOVERNANCE_STORE = "governance";
const GOVERNANCE_KEY = "scope-binding";
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
export type IndexedDbUploadCheckpointScope = Readonly<{
authorityToken: string;
namespaceToken: string;
partitionToken: string;
}>;
export type IndexedDbUploadCheckpointDependencies = Readonly<{
scope: IndexedDbUploadCheckpointScope;
factory?: IDBFactory;
blockedTimeoutMs?: number;
}>;
export type IndexedDbUploadCheckpointRuntime = Readonly<{
store: ResumableUploadCheckpointStore;
admin: ResumableUploadCheckpointAdmin;
}>;
type ScopeBinding = Readonly<{
key: typeof GOVERNANCE_KEY;
schemaVersion: 1;
authorityToken: string;
namespaceToken: string;
partitionToken: string;
}>;
type OpenFactory = (
name: string,
version?: number,
) => IDBOpenDBRequest;
export function uploadCheckpointDatabaseName(
scope: IndexedDbUploadCheckpointScope,
): string {
const snapshot = snapshotScope(scope);
const components = [
snapshot.authorityToken,
snapshot.namespaceToken,
snapshot.partitionToken,
].map((component) => `${component.length}:${component}`);
return `ca-resumable-upload-v1|${components.join("|")}`;
}
export function createIndexedDbResumableUploadCheckpointStore(
input: IndexedDbUploadCheckpointDependencies,
): ResumableUploadCheckpointStore {
return createIndexedDbResumableUploadCheckpointRuntime(input).store;
}
export function createIndexedDbResumableUploadCheckpointRuntime(
input: IndexedDbUploadCheckpointDependencies,
): IndexedDbUploadCheckpointRuntime {
const scope = snapshotScope(input.scope);
const factory =
input.factory ??
(typeof indexedDB === "undefined" ? undefined : indexedDB);
const blockedTimeoutMs =
input.blockedTimeoutMs ?? DEFAULT_BLOCKED_TIMEOUT_MS;
if (
!Number.isSafeInteger(blockedTimeoutMs) ||
blockedTimeoutMs < 1 ||
blockedTimeoutMs > 30_000
) {
throw new TypeError("Upload checkpoint blocked timeout is invalid.");
}
const openFactory: OpenFactory | undefined = factory
? factory.open.bind(factory)
: undefined;
const deleteFactory =
factory && typeof factory.deleteDatabase === "function"
? factory.deleteDatabase.bind(factory)
: undefined;
const databaseName = uploadCheckpointDatabaseName(scope);
const expectedBinding: ScopeBinding = Object.freeze({
key: GOVERNANCE_KEY,
schemaVersion: 1,
...scope,
});
let database: IDBDatabase | null = null;
let opening: Promise<BrowserDataResult<IDBDatabase>> | null = null;
let closed = false;
async function open(
signal?: AbortSignal,
): Promise<BrowserDataResult<IDBDatabase>> {
if (closed || !openFactory) {
return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", {
recovery: "RESUME",
});
}
if (signal?.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
}
if (database) return browserDataSuccess(database);
if (!opening) {
opening = openAndBind().finally(() => {
opening = null;
});
}
const result = await opening;
if (signal?.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
}
return result;
}
async function openAndBind(): Promise<BrowserDataResult<IDBDatabase>> {
let request: IDBOpenDBRequest;
try {
request = openFactory!(databaseName, DATABASE_VERSION);
} catch (error) {
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
}
const opened = await new Promise<BrowserDataResult<IDBDatabase>>(
(resolve) => {
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (result: BrowserDataResult<IDBDatabase>) => {
if (settled) {
if (result.ok) result.value.close();
return;
}
settled = true;
if (blockedTimer) clearTimeout(blockedTimer);
resolve(result);
};
request.onupgradeneeded = () => {
try {
const db = request.result;
if (!db.objectStoreNames.contains(CHECKPOINT_STORE)) {
db.createObjectStore(CHECKPOINT_STORE, {
keyPath: "uploadKey",
});
}
if (!db.objectStoreNames.contains(GOVERNANCE_STORE)) {
db.createObjectStore(GOVERNANCE_STORE, {
keyPath: "key",
});
}
} catch (error) {
try {
request.transaction?.abort();
} catch {
// The open request will surface the original closed failure.
}
finish(mapBrowserDataException(error, "UPLOAD_RECONCILE"));
}
};
request.onblocked = () => {
blockedTimer = setTimeout(() => {
finish(
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", {
retryable: true,
recovery: "RESUME",
}),
);
}, blockedTimeoutMs);
};
request.onerror = () =>
finish(
mapBrowserDataException(
request.error,
"UPLOAD_RECONCILE",
),
);
request.onsuccess = () => finish(browserDataSuccess(request.result));
},
);
if (!opened.ok) return opened;
if (closed) {
opened.value.close();
return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", {
recovery: "RESUME",
});
}
const bound = await bindScope(opened.value, expectedBinding);
if (!bound.ok) {
opened.value.close();
return bound;
}
opened.value.onversionchange = () => {
opened.value.close();
if (database === opened.value) database = null;
};
opened.value.onclose = () => {
if (database === opened.value) database = null;
};
database = opened.value;
return browserDataSuccess(opened.value);
}
const storeValue: ResumableUploadCheckpointStore = {
async read(
uploadKey: string,
signal?: AbortSignal,
): Promise<
BrowserDataResult<ResumableUploadCheckpoint | null>
> {
if (!SAFE_UPLOAD_KEY.test(uploadKey)) {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_RECONCILE",
);
}
const opened = await open(signal);
if (!opened.ok) return opened;
return await runCheckpointTransaction<
ResumableUploadCheckpoint | null
>(
opened.value,
"readonly",
signal,
(nativeStore, context) => {
const request = nativeStore.get(uploadKey);
request.onerror = () => context.nativeFailure(request.error);
request.onsuccess = () => {
if (request.result === undefined) {
context.succeed(null);
return;
}
if (!isResumableUploadCheckpoint(request.result)) {
context.fail(
browserDataFailure(
"CORRUPT_DATA",
"UPLOAD_RECONCILE",
{ recovery: "RECONCILE" },
),
);
return;
}
context.succeed(
snapshotCheckpoint(request.result),
);
};
},
);
},
async compareAndSwap(
inputValue: Parameters<
ResumableUploadCheckpointStore["compareAndSwap"]
>[0],
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
let checkpoint: ResumableUploadCheckpoint;
try {
checkpoint = snapshotCheckpoint(inputValue.checkpoint);
} catch {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_RECONCILE",
);
}
const expectedRevision = inputValue.expectedRevision;
if (
(expectedRevision !== null &&
(!Number.isSafeInteger(expectedRevision) ||
expectedRevision < 1)) ||
checkpoint.revision !== (expectedRevision ?? 0) + 1
) {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_RECONCILE",
);
}
const opened = await open(inputValue.signal);
if (!opened.ok) return opened;
return await runCheckpointTransaction<ResumableUploadCheckpoint>(
opened.value,
"readwrite",
inputValue.signal,
(nativeStore, context) => {
const request = nativeStore.get(checkpoint.uploadKey);
request.onerror = () => context.nativeFailure(request.error);
request.onsuccess = () => {
const current = request.result;
if (
(expectedRevision === null && current !== undefined) ||
(expectedRevision !== null &&
(!isResumableUploadCheckpoint(current) ||
current.revision !== expectedRevision))
) {
context.fail(
browserDataFailure(
"CONFLICT",
"UPLOAD_RECONCILE",
{ recovery: "RECONCILE" },
),
);
return;
}
const put = nativeStore.put(checkpoint);
put.onerror = () => context.nativeFailure(put.error);
put.onsuccess = () => context.succeed(checkpoint);
};
},
);
},
async remove(
inputValue: Parameters<
ResumableUploadCheckpointStore["remove"]
>[0],
): Promise<BrowserDataResult<void>> {
if (
!SAFE_UPLOAD_KEY.test(inputValue.uploadKey) ||
!Number.isSafeInteger(inputValue.expectedRevision) ||
inputValue.expectedRevision < 1
) {
return browserDataFailure(
"INVALID_INPUT",
"UPLOAD_RECONCILE",
);
}
const opened = await open(inputValue.signal);
if (!opened.ok) return opened;
return await runCheckpointTransaction<void>(
opened.value,
"readwrite",
inputValue.signal,
(nativeStore, context) => {
const request = nativeStore.get(inputValue.uploadKey);
request.onerror = () => context.nativeFailure(request.error);
request.onsuccess = () => {
if (
!isResumableUploadCheckpoint(request.result) ||
request.result.revision !== inputValue.expectedRevision
) {
context.fail(
browserDataFailure(
"CONFLICT",
"UPLOAD_RECONCILE",
{ recovery: "RECONCILE" },
),
);
return;
}
const deletion = nativeStore.delete(inputValue.uploadKey);
deletion.onerror = () =>
context.nativeFailure(deletion.error);
deletion.onsuccess = () => context.succeed(undefined);
};
},
);
},
close() {
closed = true;
database?.close();
database = null;
},
};
const store = Object.freeze(storeValue);
const adminValue: ResumableUploadCheckpointAdmin = {
async deletePartition(
signal?: AbortSignal,
): Promise<
BrowserDataResult<Readonly<{ state: "DELETED" }>>
> {
if (signal?.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
}
closed = true;
database?.close();
database = null;
if (!deleteFactory) {
return browserDataFailure(
"UNSUPPORTED",
"UPLOAD_RECONCILE",
{ recovery: "READ_ONLY" },
);
}
let request: IDBOpenDBRequest;
try {
request = deleteFactory(databaseName);
} catch (error) {
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
}
return await new Promise<
BrowserDataResult<Readonly<{ state: "DELETED" }>>
>((resolve) => {
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (
result: BrowserDataResult<Readonly<{ state: "DELETED" }>>,
) => {
if (settled) return;
settled = true;
if (blockedTimer) clearTimeout(blockedTimer);
resolve(result);
};
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal is
// intentionally observed only before dispatch so the adapter never
// reports ABORTED while deletion may still commit.
request.onblocked = () => {
blockedTimer = setTimeout(() => {
finish(
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", {
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
}),
);
}, blockedTimeoutMs);
};
request.onerror = () =>
finish(
mapBrowserDataException(
request.error,
"UPLOAD_RECONCILE",
),
);
request.onsuccess = () =>
finish(
browserDataSuccess(
Object.freeze({ state: "DELETED" as const }),
),
);
});
},
};
const admin = Object.freeze(adminValue);
return Object.freeze({ store, admin });
}
type TransactionContext<Value> = Readonly<{
succeed(value: Value): void;
fail(result: BrowserDataResult<never>): void;
nativeFailure(error: unknown): void;
}>;
async function runCheckpointTransaction<Value>(
database: IDBDatabase,
mode: IDBTransactionMode,
signal: AbortSignal | undefined,
execute: (
store: IDBObjectStore,
context: TransactionContext<Value>,
) => void,
): Promise<BrowserDataResult<Value>> {
if (signal?.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
}
return await new Promise<BrowserDataResult<Value>>((resolve) => {
let transaction: IDBTransaction;
try {
transaction = database.transaction(CHECKPOINT_STORE, mode);
} catch (error) {
resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE"));
return;
}
let value: Value | undefined;
let hasValue = false;
let failure: BrowserDataResult<never> | null = null;
let settled = false;
const finish = (result: BrowserDataResult<Value>) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", abort);
resolve(result);
};
const abort = () => {
const previousFailure = failure;
failure = browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
try {
transaction.abort();
} catch {
// The transaction may already be durably committed while its
// completion event is still queued. Wait for oncomplete/onabort so we
// never report ABORTED for a mutation that actually committed.
failure = previousFailure;
}
};
signal?.addEventListener("abort", abort, { once: true });
transaction.oncomplete = () => {
if (!hasValue) {
finish(
browserDataFailure("CORRUPT_DATA", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
}),
);
return;
}
finish(browserDataSuccess(value as Value));
};
transaction.onerror = () => {
// onabort is the terminal transaction signal.
};
transaction.onabort = () =>
finish(
failure ??
mapBrowserDataException(
transaction.error,
"UPLOAD_RECONCILE",
),
);
const context: TransactionContext<Value> = Object.freeze({
succeed(next) {
if (failure) return;
value = next;
hasValue = true;
},
fail(result) {
if (failure) return;
failure = result;
try {
transaction.abort();
} catch {
finish(result);
}
},
nativeFailure(error) {
if (failure) return;
failure = mapBrowserDataException(
error,
"UPLOAD_RECONCILE",
);
try {
transaction.abort();
} catch {
finish(failure);
}
},
});
try {
execute(transaction.objectStore(CHECKPOINT_STORE), context);
} catch (error) {
context.nativeFailure(error);
}
});
}
async function bindScope(
database: IDBDatabase,
expected: ScopeBinding,
): Promise<BrowserDataResult<void>> {
return await new Promise<BrowserDataResult<void>>((resolve) => {
let transaction: IDBTransaction;
try {
transaction = database.transaction(GOVERNANCE_STORE, "readwrite");
} catch (error) {
resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE"));
return;
}
let failure: BrowserDataResult<never> | null = null;
transaction.onerror = () => {
// onabort owns terminal resolution.
};
transaction.onabort = () =>
resolve(
failure ??
mapBrowserDataException(
transaction.error,
"UPLOAD_RECONCILE",
),
);
transaction.oncomplete = () => resolve(browserDataSuccess(undefined));
const store = transaction.objectStore(GOVERNANCE_STORE);
const request = store.get(GOVERNANCE_KEY);
request.onerror = () => {
failure = mapBrowserDataException(
request.error,
"UPLOAD_RECONCILE",
);
transaction.abort();
};
request.onsuccess = () => {
if (request.result === undefined) {
const add = store.add(expected);
add.onerror = () => {
failure = mapBrowserDataException(
add.error,
"UPLOAD_RECONCILE",
);
transaction.abort();
};
return;
}
if (!sameScopeBinding(request.result, expected)) {
failure = browserDataFailure(
"POLICY_REJECTED",
"UPLOAD_RECONCILE",
{ recovery: "READ_ONLY" },
);
transaction.abort();
}
};
});
}
function sameScopeBinding(
value: unknown,
expected: ScopeBinding,
): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return (
Object.keys(record).length === 5 &&
record.key === expected.key &&
record.schemaVersion === expected.schemaVersion &&
record.authorityToken === expected.authorityToken &&
record.namespaceToken === expected.namespaceToken &&
record.partitionToken === expected.partitionToken
);
}
function snapshotScope(
value: IndexedDbUploadCheckpointScope,
): IndexedDbUploadCheckpointScope {
if (
!value ||
typeof value !== "object" ||
!SAFE_OPAQUE_ID.test(value.authorityToken) ||
!SAFE_OPAQUE_ID.test(value.namespaceToken) ||
!SAFE_OPAQUE_ID.test(value.partitionToken)
) {
throw new TypeError("Upload checkpoint scope is invalid.");
}
return Object.freeze({
authorityToken: value.authorityToken,
namespaceToken: value.namespaceToken,
partitionToken: value.partitionToken,
});
}
function snapshotCheckpoint(
value: ResumableUploadCheckpoint,
): ResumableUploadCheckpoint {
let cloned: unknown;
try {
cloned = structuredClone(value);
} catch {
throw new TypeError("Upload checkpoint is not cloneable.");
}
if (!isResumableUploadCheckpoint(cloned)) {
throw new TypeError("Upload checkpoint is invalid.");
}
return Object.freeze({
...cloned,
fingerprint: Object.freeze({ ...cloned.fingerprint }),
acceptedParts: Object.freeze(
cloned.acceptedParts.map((part) => Object.freeze({ ...part })),
),
});
}
@@ -0,0 +1,114 @@
import type {
PresignedUploadPartCapability,
PresignedUploadPartPort,
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
import type {
UploadPartExecutor,
UploadPartReceipt,
UploadProviderResult,
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import {
MEDIA_TYPE,
isSafeUploadReceiptToken,
SHA256_HEX,
} from "./checkpoint-schema.ts";
export function createPresignedUploadPartExecutor(
inputPort: PresignedUploadPartPort,
now: () => number = Date.now,
): UploadPartExecutor<PresignedUploadPartCapability> {
const put = inputPort?.put;
if (typeof put !== "function" || typeof now !== "function") {
throw new TypeError("Presigned upload part dependency is invalid.");
}
const executor: UploadPartExecutor<PresignedUploadPartCapability> = {
async uploadPart(
input: Parameters<
UploadPartExecutor<PresignedUploadPartCapability>["uploadPart"]
>[0],
): Promise<
UploadProviderResult<UploadPartReceipt>
> {
const capability = input.capability;
let nowEpochMs: number;
try {
nowEpochMs = now();
} catch {
return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", {
retryable: true,
recovery: "RESUME",
});
}
if (
!capability ||
capability.method !== "PUT" ||
capability.binding.kind !== "UPLOAD_PART" ||
capability.binding.protocol !== input.protocol ||
capability.binding.sessionId !== input.sessionId ||
capability.binding.requestBindingSha256 !==
input.requestBindingSha256 ||
capability.binding.uploadBindingSha256 !==
input.uploadBindingSha256 ||
capability.binding.partNumber !== input.part.partNumber ||
capability.binding.offset !== input.part.offset ||
capability.binding.idempotencyKey !== input.idempotencyKey ||
capability.mediaType !== input.mediaType ||
!MEDIA_TYPE.test(capability.mediaType) ||
capability.byteLength !== input.part.byteLength ||
capability.maxBytes < capability.byteLength ||
capability.maxBytes !== input.part.byteLength ||
capability.expectedSha256 !== input.part.checksumSha256 ||
!SHA256_HEX.test(capability.expectedSha256) ||
capability.expiresAtEpochMs <= nowEpochMs ||
!(input.bytes instanceof Uint8Array) ||
input.bytes.byteLength !== input.part.byteLength ||
typeof capability.capabilityReceipt !== "string"
) {
return browserDataFailure("POLICY_REJECTED", "UPLOAD_PART");
}
let uploaded;
try {
uploaded = await put.call(inputPort, {
capability,
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
uploadBindingSha256: input.uploadBindingSha256,
partNumber: input.part.partNumber,
offset: input.part.offset,
byteLength: input.part.byteLength,
checksumSha256: input.part.checksumSha256,
idempotencyKey: input.idempotencyKey,
bytes: Uint8Array.from(input.bytes),
signal: input.signal,
});
} catch {
return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", {
retryable: true,
recovery: "RESUME",
});
}
if (!uploaded.ok) return uploaded;
if (
uploaded.value.bytesWritten !== input.part.byteLength ||
uploaded.value.checksumSha256 !== input.part.checksumSha256 ||
typeof uploaded.value.receiptToken !== "string" ||
!isSafeUploadReceiptToken(uploaded.value.receiptToken)
) {
return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_PART", {
recovery: "RECONCILE",
});
}
return browserDataSuccess(
Object.freeze({
...input.part,
receiptToken: uploaded.value.receiptToken,
}),
);
},
};
return Object.freeze(executor);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
export type ResumableUploadRuntimePolicy = Readonly<{
partSizeBytes: number;
maxFileBytes: number;
maxPartCount: number;
maxConcurrency: number;
maxInFlightBytes: number;
partBufferCopyFactor: number;
maxSourceChunkBytes: number;
maxRetries: number;
retryBaseDelayMs: number;
retryMaxDelayMs: number;
maxRetryAfterMs: number;
capabilityRefreshSkewMs: number;
maxSessionLifetimeMs: number;
providerAttemptTimeoutMs: number;
}>;
const MIB = 1024 * 1024;
const GIB = 1024 * MIB;
const ABSOLUTE_LIMITS = Object.freeze({
maxPartSizeBytes: 64 * MIB,
maxFileBytes: 100 * GIB,
maxPartCount: 10_000,
maxConcurrency: 8,
maxInFlightBytes: 256 * MIB,
maxPartBufferCopyFactor: 8,
maxSourceChunkBytes: 64 * MIB,
maxRetries: 8,
maxRetryDelayMs: 60_000,
maxRetryAfterMs: 60_000,
maxCapabilityRefreshSkewMs: 5 * 60_000,
maxSessionLifetimeMs: 7 * 24 * 60 * 60_000,
maxProviderAttemptTimeoutMs: 2 * 60_000,
});
const DEFAULT_POLICY: ResumableUploadRuntimePolicy = Object.freeze({
partSizeBytes: 5 * MIB,
maxFileBytes: 5 * GIB,
maxPartCount: 1_024,
maxConcurrency: 3,
maxInFlightBytes: 20 * MIB,
partBufferCopyFactor: 4,
maxSourceChunkBytes: 8 * MIB,
maxRetries: 3,
retryBaseDelayMs: 250,
retryMaxDelayMs: 5_000,
maxRetryAfterMs: 30_000,
capabilityRefreshSkewMs: 5_000,
maxSessionLifetimeMs: 24 * 60 * 60_000,
providerAttemptTimeoutMs: 30_000,
});
export function resolveResumableUploadRuntimePolicy(
input: Partial<ResumableUploadRuntimePolicy> = {},
): ResumableUploadRuntimePolicy {
const policy: ResumableUploadRuntimePolicy = Object.freeze({
...DEFAULT_POLICY,
...input,
});
if (
!positiveSafeInteger(policy.partSizeBytes) ||
policy.partSizeBytes > ABSOLUTE_LIMITS.maxPartSizeBytes ||
!positiveSafeInteger(policy.maxFileBytes) ||
policy.maxFileBytes > ABSOLUTE_LIMITS.maxFileBytes ||
!positiveSafeInteger(policy.maxPartCount) ||
policy.maxPartCount > ABSOLUTE_LIMITS.maxPartCount ||
!positiveSafeInteger(policy.maxConcurrency) ||
policy.maxConcurrency > ABSOLUTE_LIMITS.maxConcurrency ||
!positiveSafeInteger(policy.maxInFlightBytes) ||
policy.maxInFlightBytes > ABSOLUTE_LIMITS.maxInFlightBytes ||
!positiveSafeInteger(policy.partBufferCopyFactor) ||
policy.partBufferCopyFactor >
ABSOLUTE_LIMITS.maxPartBufferCopyFactor ||
policy.maxInFlightBytes <
policy.partSizeBytes * policy.partBufferCopyFactor ||
!positiveSafeInteger(policy.maxSourceChunkBytes) ||
policy.maxSourceChunkBytes >
ABSOLUTE_LIMITS.maxSourceChunkBytes ||
!nonNegativeSafeInteger(policy.maxRetries) ||
policy.maxRetries > ABSOLUTE_LIMITS.maxRetries ||
!positiveSafeInteger(policy.retryBaseDelayMs) ||
policy.retryBaseDelayMs > ABSOLUTE_LIMITS.maxRetryDelayMs ||
!positiveSafeInteger(policy.retryMaxDelayMs) ||
policy.retryMaxDelayMs > ABSOLUTE_LIMITS.maxRetryDelayMs ||
policy.retryBaseDelayMs > policy.retryMaxDelayMs ||
!nonNegativeSafeInteger(policy.maxRetryAfterMs) ||
policy.maxRetryAfterMs > ABSOLUTE_LIMITS.maxRetryAfterMs ||
!nonNegativeSafeInteger(policy.capabilityRefreshSkewMs) ||
policy.capabilityRefreshSkewMs >
ABSOLUTE_LIMITS.maxCapabilityRefreshSkewMs ||
!positiveSafeInteger(policy.maxSessionLifetimeMs) ||
policy.maxSessionLifetimeMs >
ABSOLUTE_LIMITS.maxSessionLifetimeMs ||
!positiveSafeInteger(policy.providerAttemptTimeoutMs) ||
policy.providerAttemptTimeoutMs >
ABSOLUTE_LIMITS.maxProviderAttemptTimeoutMs ||
Math.ceil(policy.maxFileBytes / policy.partSizeBytes) >
policy.maxPartCount
) {
throw new TypeError("Resumable upload policy is invalid.");
}
return policy;
}
function positiveSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
function nonNegativeSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
@@ -0,0 +1,600 @@
import type {
ResumableUploadSource,
UploadFileFingerprint,
UploadPartDescriptor,
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
import type {
BrowserDataFailure,
BrowserDataOperation,
BrowserDataResult,
} from "../../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import { samePart } from "./checkpoint-schema.ts";
export type UploadCrypto = Readonly<{
digestSha256(bytes: Uint8Array): Promise<ArrayBuffer>;
}>;
export type UploadSourceSnapshot =
| Readonly<{
kind: "FILE_BYTE_SOURCE";
byteLength: number;
stream(
signal: AbortSignal,
): AsyncIterable<BrowserDataResult<Uint8Array>>;
}>
| Readonly<{
kind: "RANGE_READER";
byteLength: number;
readRange(input: Readonly<{
offset: number;
length: number;
signal: AbortSignal;
}>): Promise<BrowserDataResult<Uint8Array>>;
}>;
export type UploadPartManifest = Readonly<{
fingerprint: UploadFileFingerprint;
parts: readonly UploadPartDescriptor[];
}>;
export function snapshotUploadSource(
source: ResumableUploadSource,
): UploadSourceSnapshot {
if (!source || typeof source !== "object") {
throw new TypeError("Upload source is invalid.");
}
if (source.kind === "FILE_BYTE_SOURCE") {
const bytes = source.bytes;
const stream = bytes?.stream;
if (
typeof stream !== "function" ||
!positiveSafeInteger(bytes.byteLength)
) {
throw new TypeError("Upload byte source is invalid.");
}
return Object.freeze({
kind: "FILE_BYTE_SOURCE" as const,
byteLength: bytes.byteLength,
stream(signal: AbortSignal) {
return stream.call(bytes, signal);
},
});
}
if (source.kind === "RANGE_READER") {
const reader = source.reader;
const readRange = reader?.readRange;
if (
typeof readRange !== "function" ||
!positiveSafeInteger(reader.byteLength)
) {
throw new TypeError("Upload range source is invalid.");
}
return Object.freeze({
kind: "RANGE_READER" as const,
byteLength: reader.byteLength,
async readRange(input) {
return await readRange.call(reader, input);
},
});
}
throw new TypeError("Upload source kind is invalid.");
}
export function snapshotUploadCrypto(crypto: Crypto): UploadCrypto {
const subtle = crypto?.subtle;
const digest = subtle?.digest;
if (typeof digest !== "function") {
throw new TypeError("Upload crypto capability is invalid.");
}
return Object.freeze({
async digestSha256(bytes: Uint8Array): Promise<ArrayBuffer> {
return await digest.call(
subtle,
"SHA-256",
Uint8Array.from(bytes),
);
},
});
}
export async function buildUploadPartManifest(input: Readonly<{
source: UploadSourceSnapshot;
partSizeBytes: number;
maxPartCount: number;
maxSourceChunkBytes: number;
crypto: UploadCrypto;
signal: AbortSignal;
onPreparedBytes?: (bytes: number) => void;
}>): Promise<BrowserDataResult<UploadPartManifest>> {
const parts: UploadPartDescriptor[] = [];
let preparedBytes = 0;
for await (const partResult of iterateUploadParts({
source: input.source,
partSizeBytes: input.partSizeBytes,
maxSourceChunkBytes: input.maxSourceChunkBytes,
signal: input.signal,
operation: "UPLOAD_SESSION",
})) {
if (!partResult.ok) return partResult;
if (parts.length >= input.maxPartCount) {
return browserDataFailure("LIMIT_EXCEEDED", "UPLOAD_SESSION");
}
const checksum = await digestHex(
input.crypto,
partResult.value.bytes,
input.signal,
"UPLOAD_SESSION",
);
if (!checksum.ok) return checksum;
const descriptor: UploadPartDescriptor = Object.freeze({
partNumber: partResult.value.partNumber,
offset: partResult.value.offset,
byteLength: partResult.value.bytes.byteLength,
checksumSha256: checksum.value,
});
parts.push(descriptor);
preparedBytes += descriptor.byteLength;
try {
input.onPreparedBytes?.(preparedBytes);
} catch {
// Progress observation cannot affect transfer correctness.
}
}
if (
parts.length === 0 ||
preparedBytes !== input.source.byteLength
) {
return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_SESSION", {
recovery: "RESELECT",
});
}
const canonical = canonicalPartManifest(
input.source.byteLength,
input.partSizeBytes,
parts,
);
const fingerprintDigest = await digestHex(
input.crypto,
canonical,
input.signal,
"UPLOAD_SESSION",
);
if (!fingerprintDigest.ok) return fingerprintDigest;
const fingerprint: UploadFileFingerprint = Object.freeze({
algorithm: "SHA-256-PARTS-V1",
digestHex: fingerprintDigest.value,
byteLength: input.source.byteLength,
partSizeBytes: input.partSizeBytes,
partCount: parts.length,
});
return browserDataSuccess(
Object.freeze({
fingerprint,
parts: Object.freeze(parts),
}),
);
}
export async function readAndVerifyRangePart(input: Readonly<{
source: Extract<UploadSourceSnapshot, { kind: "RANGE_READER" }>;
part: UploadPartDescriptor;
crypto: UploadCrypto;
signal: AbortSignal;
}>): Promise<BrowserDataResult<Uint8Array>> {
if (input.signal.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_PART");
}
let result: BrowserDataResult<Uint8Array>;
try {
result = await input.source.readRange({
offset: input.part.offset,
length: input.part.byteLength,
signal: input.signal,
});
} catch {
return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", {
retryable: true,
recovery: "RESUME",
});
}
if (!result.ok) return remapFailure(result.error, "UPLOAD_PART");
if (
!(result.value instanceof Uint8Array) ||
result.value.byteLength !== input.part.byteLength
) {
return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_PART", {
recovery: "RESELECT",
});
}
const bytes = Uint8Array.from(result.value);
const checksum = await digestHex(
input.crypto,
bytes,
input.signal,
"UPLOAD_PART",
);
if (!checksum.ok) return checksum;
return checksum.value === input.part.checksumSha256
? browserDataSuccess(bytes)
: browserDataFailure("STALE_RESULT", "UPLOAD_PART", {
recovery: "RESELECT",
});
}
export async function verifyUploadPartBytes(input: Readonly<{
bytes: Uint8Array;
part: UploadPartDescriptor;
crypto: UploadCrypto;
signal: AbortSignal;
}>): Promise<BrowserDataResult<Uint8Array>> {
if (
!(input.bytes instanceof Uint8Array) ||
input.bytes.byteLength !== input.part.byteLength
) {
return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_PART", {
recovery: "RESELECT",
});
}
const bytes = Uint8Array.from(input.bytes);
const checksum = await digestHex(
input.crypto,
bytes,
input.signal,
"UPLOAD_PART",
);
if (!checksum.ok) return checksum;
return checksum.value === input.part.checksumSha256
? browserDataSuccess(bytes)
: browserDataFailure("STALE_RESULT", "UPLOAD_PART", {
recovery: "RESELECT",
});
}
export async function digestRequestBinding(input: Readonly<{
uploadKey: string;
purpose: string;
mediaType: string;
fingerprint: UploadFileFingerprint;
crypto: UploadCrypto;
signal: AbortSignal;
}>): Promise<BrowserDataResult<string>> {
const canonical = new TextEncoder().encode(
[
"RESUMABLE-UPLOAD-BINDING-V1",
input.uploadKey,
input.purpose,
input.mediaType,
input.fingerprint.algorithm,
input.fingerprint.digestHex,
String(input.fingerprint.byteLength),
String(input.fingerprint.partSizeBytes),
String(input.fingerprint.partCount),
].join("\n"),
);
return await digestHex(
input.crypto,
canonical,
input.signal,
"UPLOAD_SESSION",
);
}
export async function deriveUploadIdempotencyKey(input: Readonly<{
label: "CREATE" | "PART" | "COMPLETE" | "ABORT";
requestBindingSha256: string;
sessionId?: string;
part?: UploadPartDescriptor;
crypto: UploadCrypto;
signal: AbortSignal;
}>): Promise<BrowserDataResult<string>> {
const fields = [
"RESUMABLE-UPLOAD-IDEMPOTENCY-V1",
input.label,
input.requestBindingSha256,
input.sessionId ?? "-",
];
if (input.part) {
fields.push(
String(input.part.partNumber),
String(input.part.offset),
String(input.part.byteLength),
input.part.checksumSha256,
);
}
const digest = await digestHex(
input.crypto,
new TextEncoder().encode(fields.join("\n")),
input.signal,
input.label === "PART"
? "UPLOAD_PART"
: input.label === "COMPLETE"
? "UPLOAD_COMPLETE"
: input.label === "ABORT"
? "UPLOAD_ABORT"
: "UPLOAD_SESSION",
);
return digest.ok
? browserDataSuccess(`upload-${input.label.toLowerCase()}-${digest.value}`)
: digest;
}
export async function digestUploadSessionBinding(input: Readonly<{
requestBindingSha256: string;
sessionId: string;
fingerprint: UploadFileFingerprint;
crypto: UploadCrypto;
signal: AbortSignal;
}>): Promise<BrowserDataResult<string>> {
return await digestHex(
input.crypto,
new TextEncoder().encode(
[
"RESUMABLE-UPLOAD-SESSION-BINDING-V1",
input.requestBindingSha256,
input.sessionId,
input.fingerprint.algorithm,
input.fingerprint.digestHex,
String(input.fingerprint.byteLength),
String(input.fingerprint.partSizeBytes),
String(input.fingerprint.partCount),
].join("\n"),
),
input.signal,
"UPLOAD_PART",
);
}
export async function* iterateUploadParts(input: Readonly<{
source: UploadSourceSnapshot;
partSizeBytes: number;
maxSourceChunkBytes: number;
signal: AbortSignal;
operation: "UPLOAD_SESSION" | "UPLOAD_PART";
}>): AsyncIterable<
BrowserDataResult<
Readonly<{
partNumber: number;
offset: number;
bytes: Uint8Array;
}>
>
> {
if (input.source.kind === "RANGE_READER") {
let partNumber = 1;
for (
let offset = 0;
offset < input.source.byteLength;
offset += input.partSizeBytes
) {
if (input.signal.aborted) {
yield browserDataFailure("ABORTED", input.operation);
return;
}
const length = Math.min(
input.partSizeBytes,
input.source.byteLength - offset,
);
let result: BrowserDataResult<Uint8Array>;
try {
result = await input.source.readRange({
offset,
length,
signal: input.signal,
});
} catch {
yield browserDataFailure("UNAVAILABLE", input.operation, {
retryable: true,
recovery: "RESUME",
});
return;
}
if (!result.ok) {
yield remapFailure(result.error, input.operation);
return;
}
if (
!(result.value instanceof Uint8Array) ||
result.value.byteLength !== length
) {
yield browserDataFailure("INTEGRITY_FAILED", input.operation, {
recovery: "RESELECT",
});
return;
}
yield browserDataSuccess(
Object.freeze({
partNumber,
offset,
bytes: Uint8Array.from(result.value),
}),
);
partNumber += 1;
}
return;
}
let iterable: AsyncIterable<BrowserDataResult<Uint8Array>>;
try {
iterable = input.source.stream(input.signal);
} catch {
yield browserDataFailure("UNAVAILABLE", input.operation, {
retryable: true,
recovery: "RESUME",
});
return;
}
let partNumber = 1;
let offset = 0;
let totalBytes = 0;
let buffer = new Uint8Array(input.partSizeBytes);
let bufferedBytes = 0;
try {
for await (const chunkResult of iterable) {
if (input.signal.aborted) {
yield browserDataFailure("ABORTED", input.operation);
return;
}
if (!chunkResult.ok) {
yield remapFailure(chunkResult.error, input.operation);
return;
}
const chunk = chunkResult.value;
if (
!(chunk instanceof Uint8Array) ||
chunk.byteLength < 1 ||
chunk.byteLength > input.maxSourceChunkBytes ||
totalBytes + chunk.byteLength > input.source.byteLength
) {
yield browserDataFailure(
chunk instanceof Uint8Array &&
chunk.byteLength > input.maxSourceChunkBytes
? "LIMIT_EXCEEDED"
: "INTEGRITY_FAILED",
input.operation,
{ recovery: "RESELECT" },
);
return;
}
let position = 0;
while (position < chunk.byteLength) {
const length = Math.min(
buffer.byteLength - bufferedBytes,
chunk.byteLength - position,
);
buffer.set(chunk.subarray(position, position + length), bufferedBytes);
position += length;
bufferedBytes += length;
totalBytes += length;
if (bufferedBytes === buffer.byteLength) {
yield browserDataSuccess(
Object.freeze({
partNumber,
offset,
bytes: buffer,
}),
);
offset += buffer.byteLength;
partNumber += 1;
buffer = new Uint8Array(input.partSizeBytes);
bufferedBytes = 0;
}
}
}
} catch {
yield browserDataFailure("UNAVAILABLE", input.operation, {
retryable: true,
recovery: "RESUME",
});
return;
}
if (totalBytes !== input.source.byteLength) {
yield browserDataFailure("INTEGRITY_FAILED", input.operation, {
recovery: "RESELECT",
});
return;
}
if (bufferedBytes > 0) {
yield browserDataSuccess(
Object.freeze({
partNumber,
offset,
bytes: buffer.slice(0, bufferedBytes),
}),
);
}
}
export function findManifestPart(
manifest: UploadPartManifest,
partNumber: number,
): UploadPartDescriptor | null {
return manifest.parts[partNumber - 1] ?? null;
}
export function verifyPartAgainstManifest(
part: UploadPartDescriptor,
manifest: UploadPartManifest,
): boolean {
const expected = findManifestPart(manifest, part.partNumber);
return Boolean(expected && samePart(part, expected));
}
async function digestHex(
crypto: UploadCrypto,
bytes: Uint8Array,
signal: AbortSignal,
operation: BrowserDataOperation,
): Promise<BrowserDataResult<string>> {
if (signal.aborted) {
return browserDataFailure("ABORTED", operation);
}
try {
const digest = new Uint8Array(await crypto.digestSha256(bytes));
if (signal.aborted) {
return browserDataFailure("ABORTED", operation);
}
if (digest.byteLength !== 32) {
return browserDataFailure("UNAVAILABLE", operation, {
retryable: true,
recovery: "RESUME",
});
}
return browserDataSuccess(
Array.from(
digest,
(byte) => byte.toString(16).padStart(2, "0"),
).join(""),
);
} catch {
return browserDataFailure("UNAVAILABLE", operation, {
retryable: true,
recovery: "RESUME",
});
}
}
function canonicalPartManifest(
byteLength: number,
partSizeBytes: number,
parts: readonly UploadPartDescriptor[],
): Uint8Array {
return new TextEncoder().encode(
[
"SHA-256-PARTS-V1",
String(byteLength),
String(partSizeBytes),
String(parts.length),
...parts.map((part) =>
[
part.partNumber,
part.offset,
part.byteLength,
part.checksumSha256,
].join(":"),
),
].join("\n"),
);
}
function remapFailure(
failure: BrowserDataFailure,
operation: BrowserDataOperation,
): BrowserDataResult<never> {
return Object.freeze({
ok: false,
error: Object.freeze({
code: failure.code,
operation,
retryable: failure.retryable,
recovery: failure.recovery,
}),
});
}
function positiveSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) > 0;
}
@@ -0,0 +1,222 @@
import {
SAFE_REGISTRY_ID,
SAFE_UPLOAD_KEY,
} from "./checkpoint-schema.ts";
export type UploadCancellationListener = (
uploadKey: string,
) => void;
/**
* Ephemeral same-origin coordination only. Messages are never persisted and
* backend abort/idempotency remains the authoritative state transition.
*
* A runtime that receives this dependency owns it and closes it with the
* runtime. Do not share one channel instance between runtimes.
*/
export interface UploadCancellationChannel {
publish(uploadKey: string): boolean;
subscribe(listener: UploadCancellationListener): () => void;
close(): void;
}
export type UploadCancellationBroadcastFacade = Readonly<{
postMessage(message: unknown): void;
addEventListener(
type: "message",
listener: (event: Readonly<{ data: unknown }>) => void,
): void;
removeEventListener(
type: "message",
listener: (event: Readonly<{ data: unknown }>) => void,
): void;
close(): void;
}>;
export type BrowserUploadCancellationDependencies = Readonly<{
channelName?: string;
host?: Record<string, unknown>;
createChannel?: (
channelName: string,
) => UploadCancellationBroadcastFacade;
}>;
const DEFAULT_CHANNEL_NAME = "ca-resumable-upload-cancel-v1";
const PROTOCOL = "RESUMABLE_UPLOAD_CANCEL_V1";
const MESSAGE_KEYS = Object.freeze([
"protocol",
"uploadKey",
] as const);
/**
* Creates a strict BroadcastChannel-backed cancellation signal.
*
* Unsupported or policy-disabled BroadcastChannel returns `undefined`; upload
* correctness still relies on Web Locks, durable CAS and backend idempotency,
* while an explicit abort waits for the lock under its caller deadline.
*/
export function createBrowserUploadCancellationChannel(
dependencies: BrowserUploadCancellationDependencies = {},
): UploadCancellationChannel | undefined {
const channelName =
dependencies.channelName ?? DEFAULT_CHANNEL_NAME;
if (!SAFE_REGISTRY_ID.test(channelName)) {
throw new TypeError(
"Upload cancellation channel name is invalid.",
);
}
let channel: UploadCancellationBroadcastFacade;
try {
channel = dependencies.createChannel
? dependencies.createChannel(channelName)
: createNativeChannel(
dependencies.host ??
(globalThis as unknown as Record<string, unknown>),
channelName,
);
} catch {
return undefined;
}
if (!isBroadcastFacade(channel)) return undefined;
const listeners = new Set<UploadCancellationListener>();
let closed = false;
const receive = (event: Readonly<{ data: unknown }>) => {
if (closed || !isCancellationMessage(event.data)) return;
for (const listener of [...listeners]) {
try {
listener(event.data.uploadKey);
} catch {
// One feature listener cannot prevent delivery to other runtimes.
}
}
};
try {
channel.addEventListener("message", receive);
} catch {
try {
channel.close();
} catch {
// Construction still fails closed when cleanup is unavailable.
}
return undefined;
}
return Object.freeze({
publish(uploadKey: string): boolean {
if (closed || !SAFE_UPLOAD_KEY.test(uploadKey)) return false;
try {
channel.postMessage(
Object.freeze({
protocol: PROTOCOL,
uploadKey,
}),
);
return true;
} catch {
return false;
}
},
subscribe(
listener: UploadCancellationListener,
): () => void {
if (closed || typeof listener !== "function") {
throw new TypeError(
"Upload cancellation listener is invalid.",
);
}
listeners.add(listener);
let subscribed = true;
return () => {
if (!subscribed) return;
subscribed = false;
listeners.delete(listener);
};
},
close(): void {
if (closed) return;
closed = true;
listeners.clear();
try {
channel.removeEventListener("message", receive);
} catch {
// Closing remains terminal even if the host rejects cleanup.
}
try {
channel.close();
} catch {
// Closing remains terminal even if the host rejects cleanup.
}
},
});
}
function createNativeChannel(
host: Record<string, unknown>,
channelName: string,
): UploadCancellationBroadcastFacade {
const constructor = safeGet(host, "BroadcastChannel");
if (typeof constructor !== "function") {
throw new TypeError("BroadcastChannel is unavailable.");
}
return Reflect.construct(constructor, [
channelName,
]) as UploadCancellationBroadcastFacade;
}
function isBroadcastFacade(
value: unknown,
): value is UploadCancellationBroadcastFacade {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return [
"postMessage",
"addEventListener",
"removeEventListener",
"close",
].every((method) => typeof safeGet(candidate, method) === "function");
}
function isCancellationMessage(
value: unknown,
): value is Readonly<{
protocol: typeof PROTOCOL;
uploadKey: string;
}> {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value)
) {
return false;
}
const keys = Object.keys(value).sort();
const expected = [...MESSAGE_KEYS].sort();
if (
keys.length !== expected.length ||
!keys.every((key, index) => key === expected[index])
) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
candidate.protocol === PROTOCOL &&
typeof candidate.uploadKey === "string" &&
SAFE_UPLOAD_KEY.test(candidate.uploadKey)
);
}
function safeGet(
target: Record<string, unknown>,
property: string,
): unknown {
try {
return Reflect.get(target, property);
} catch {
return undefined;
}
}
@@ -0,0 +1,58 @@
import { SAFE_REGISTRY_ID, SAFE_UPLOAD_KEY } from "./checkpoint-schema.ts";
type LockManagerLike = {
request<Value>(
name: string,
options: Readonly<{ mode: "exclusive"; signal?: AbortSignal }>,
callback: (lock: unknown) => Promise<Value>,
): Promise<Value>;
};
export interface UploadMutationLock {
run<Value>(
uploadKey: string,
signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value>;
}
export function createResumableUploadWebLock(
lockManager: LockManager,
lockNamespace = "ca-resumable-upload-v1",
): UploadMutationLock {
if (!SAFE_REGISTRY_ID.test(lockNamespace)) {
throw new TypeError("Upload mutation lock namespace is invalid.");
}
const request = (lockManager as unknown as LockManagerLike)?.request;
if (typeof request !== "function") {
throw new TypeError("Upload mutation lock manager is invalid.");
}
return Object.freeze({
async run<Value>(
uploadKey: string,
signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value> {
if (!SAFE_UPLOAD_KEY.test(uploadKey)) {
throw new TypeError("Upload mutation lock key is invalid.");
}
if (signal.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
return await (request.call(
lockManager,
`${lockNamespace}:${uploadKey}`,
{ mode: "exclusive", signal },
async (lock) => {
if (!lock) {
throw new DOMException(
"The upload mutation lock is unavailable.",
"InvalidStateError",
);
}
return await task();
},
) as Promise<Value>);
},
});
}
+14
View File
@@ -0,0 +1,14 @@
export {
createDefaultPublicCachePolicy,
resolvePublicCachePolicy,
type PublicCacheRuntimePolicy,
type PublicCacheSafeObservation,
type PublicCacheSafeObserver,
} from "./public-cache-policy.ts";
export {
computePublicCacheManifestDigestHex,
createPublicCacheWebLock,
createPublicResponseCacheAdapter,
type PublicCacheMutationLock,
type PublicResponseCacheDependencies,
} from "./public-response-cache-adapter.ts";
@@ -0,0 +1,199 @@
import type {
BrowserDataFailureCode,
BrowserDataOperation,
} from "../../application/ports/browser-file-storage/shared.ts";
export type PublicCacheRuntimePolicy = Readonly<{
origin: string;
ownedCachePrefix: string;
mutationLockName: string;
maxEntryBytes: number;
maxReleaseBytes: number;
maxEntriesPerRelease: number;
retainedPreviousReleaseCount: number;
allowedRequestHeaderNames: readonly string[];
allowedVaryHeaderNames: readonly string[];
allowedResponseHeaderNames: readonly string[];
unknownResponseHeaderAction: "REJECT" | "STRIP";
allowedQueryParameterNames: readonly string[];
forbiddenQueryParameterNames: readonly string[];
isQueryParameterValueAllowed: (name: string, value: string) => boolean;
isContentTypeAllowed: (contentType: string) => boolean;
isReleaseRegistryIdAllowed: (releaseRegistryId: string) => boolean;
}>;
export type PublicCacheSafeObservation = Readonly<{
operation: BrowserDataOperation;
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
failureCode?: BrowserDataFailureCode;
releaseRegistryId?: string;
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "GT_16MiB";
entryBucket?: "0" | "1_10" | "11_100" | "GT_100";
}>;
export type PublicCacheSafeObserver = (
observation: PublicCacheSafeObservation,
) => void;
const RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
const CACHE_PREFIX = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,63}:$/u;
const DEFAULT_PUBLIC_CONTENT_TYPE =
/^(?:application\/(?:javascript|json|manifest\+json|wasm)|font\/[a-z0-9.+-]+|image\/[a-z0-9.+-]+|text\/(?:css|javascript|plain))(?:\s*;.*)?$/iu;
export function createDefaultPublicCachePolicy(
origin: string,
): PublicCacheRuntimePolicy {
return resolvePublicCachePolicy({
origin,
ownedCachePrefix: "ca-public-v1:",
mutationLockName: "ca-public-v1:mutation",
maxEntryBytes: 16 * 1024 * 1024,
maxReleaseBytes: 128 * 1024 * 1024,
maxEntriesPerRelease: 500,
retainedPreviousReleaseCount: 1,
allowedRequestHeaderNames: ["accept", "accept-language"],
allowedVaryHeaderNames: [],
allowedResponseHeaderNames: [
"cache-control",
"content-language",
"content-type",
"etag",
"last-modified",
"vary",
],
unknownResponseHeaderAction: "STRIP",
allowedQueryParameterNames: [],
forbiddenQueryParameterNames: [
"access_token",
"api_key",
"auth",
"email",
"jwt",
"session",
"token",
"user",
],
isQueryParameterValueAllowed: () => false,
isContentTypeAllowed: (contentType) =>
DEFAULT_PUBLIC_CONTENT_TYPE.test(contentType),
isReleaseRegistryIdAllowed: (releaseRegistryId) =>
RELEASE_ID.test(releaseRegistryId),
});
}
export function resolvePublicCachePolicy(
policy: PublicCacheRuntimePolicy,
): PublicCacheRuntimePolicy {
const normalized: PublicCacheRuntimePolicy = Object.freeze({
...policy,
origin: new URL(policy.origin).origin,
allowedRequestHeaderNames: Object.freeze(
policy.allowedRequestHeaderNames.map((name) => name.toLowerCase()),
),
allowedVaryHeaderNames: Object.freeze(
policy.allowedVaryHeaderNames.map((name) => name.toLowerCase()),
),
allowedResponseHeaderNames: Object.freeze(
policy.allowedResponseHeaderNames.map((name) => name.toLowerCase()),
),
allowedQueryParameterNames: Object.freeze(
policy.allowedQueryParameterNames.map((name) => name.toLowerCase()),
),
forbiddenQueryParameterNames: Object.freeze(
policy.forbiddenQueryParameterNames.map((name) => name.toLowerCase()),
),
});
assertPublicCachePolicy(normalized);
return normalized;
}
export function assertPublicCachePolicy(
policy: PublicCacheRuntimePolicy,
): void {
const origin = new URL(policy.origin);
if (
origin.origin !== policy.origin ||
!isAllowedPublicCacheOrigin(origin) ||
!CACHE_PREFIX.test(policy.ownedCachePrefix) ||
policy.mutationLockName.length === 0 ||
!positiveSafeInteger(policy.maxEntryBytes) ||
!positiveSafeInteger(policy.maxReleaseBytes) ||
policy.maxEntryBytes > policy.maxReleaseBytes ||
!positiveSafeInteger(policy.maxEntriesPerRelease) ||
policy.maxEntriesPerRelease > 10_000 ||
!Number.isSafeInteger(policy.retainedPreviousReleaseCount) ||
policy.retainedPreviousReleaseCount < 1 ||
policy.retainedPreviousReleaseCount > 5 ||
!headerNameList(policy.allowedRequestHeaderNames) ||
!headerNameList(policy.allowedVaryHeaderNames) ||
!headerNameList(policy.allowedResponseHeaderNames) ||
!["REJECT", "STRIP"].includes(policy.unknownResponseHeaderAction) ||
!queryNameList(policy.allowedQueryParameterNames) ||
policy.allowedVaryHeaderNames.some(
(name) => !policy.allowedRequestHeaderNames.includes(name),
) ||
policy.forbiddenQueryParameterNames.some((name) => name.length === 0) ||
policy.allowedQueryParameterNames.some((name) =>
policy.forbiddenQueryParameterNames.includes(name),
)
) {
throw new TypeError("Public Cache Storage policy is invalid.");
}
}
export function isAllowedPublicCacheOrigin(url: URL): boolean {
return (
url.protocol === "https:" ||
(url.protocol === "http:" &&
(url.hostname === "localhost" ||
url.hostname === "[::1]" ||
/^127(?:\.\d{1,3}){3}$/u.test(url.hostname)))
);
}
export function cacheByteBucket(
byteLength: number,
): NonNullable<PublicCacheSafeObservation["byteBucket"]> {
if (byteLength === 0) return "0";
if (byteLength <= 1024 * 1024) return "1B_1MiB";
if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB";
return "GT_16MiB";
}
export function cacheEntryBucket(
count: number,
): NonNullable<PublicCacheSafeObservation["entryBucket"]> {
if (count === 0) return "0";
if (count <= 10) return "1_10";
if (count <= 100) return "11_100";
return "GT_100";
}
export function observePublicCacheSafely(
observer: PublicCacheSafeObserver | undefined,
observation: PublicCacheSafeObservation,
): void {
try {
observer?.(Object.freeze({ ...observation }));
} catch {
// Cache behavior never depends on observability.
}
}
function positiveSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
function headerNameList(names: readonly string[]): boolean {
return (
new Set(names).size === names.length &&
names.every((name) => /^[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(name))
);
}
function queryNameList(names: readonly string[]): boolean {
return (
new Set(names).size === names.length &&
names.every((name) => /^[a-z0-9][a-z0-9._-]{0,63}$/u.test(name))
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
import {
isCacheInvalidationOpaqueIdentifier,
type CacheInvalidationTopicDefinition,
} from "../../contracts/cache-invalidation.ts";
import {
createBrowserCrossContextInvalidation,
type BroadcastChannelFacade,
type BroadcastMessageListener,
type BrowserCrossContextInvalidation,
type CrossContextInvalidationObservation,
type StorageEventTargetFacade,
type StoragePulseFacade,
type StoragePulseListener,
} from "./browser-cross-context-invalidation.ts";
export type BrowserCrossContextHostDependencies = Readonly<{
host?: Record<string, unknown>;
cacheEpoch: string;
topics: readonly CacheInvalidationTopicDefinition[];
observe?: (observation: CrossContextInvalidationObservation) => void;
}>;
type NativeBroadcastChannel = Readonly<{
postMessage(value: unknown): void;
addEventListener(type: string, listener: (event: unknown) => void): void;
removeEventListener(
type: string,
listener: (event: unknown) => void,
): void;
close(): void;
}>;
const CHANNEL_NAME = "ca-client-cache-invalidation-v1";
const STORAGE_PULSE_KEY =
"ca-frontend:cache-invalidation:v1:pulse";
/**
* Captures native capabilities without allowing a SecurityError getter or a
* missing random source to fail application boot.
*/
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>);
if (
!isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch)
) {
return undefined;
}
const createOpaqueId = randomIdFactory(host);
if (!createOpaqueId) return undefined;
const sourceId = createOpaqueId("tab");
const sourceEpoch = createOpaqueId("page");
if (!sourceId || !sourceEpoch) return undefined;
return createBrowserCrossContextInvalidation({
channelName: CHANNEL_NAME,
storagePulseKey: STORAGE_PULSE_KEY,
sourceId,
sourceEpoch,
cacheEpoch: dependencies.cacheEpoch,
topics: dependencies.topics,
createEventId: () => {
const eventId = createOpaqueId("event");
if (!eventId) throw new TypeError("Secure random is unavailable.");
return eventId;
},
createBroadcastChannel: broadcastFactory(host),
storage: storageFacade(host),
storageEvents: storageEventTarget(host),
observe: dependencies.observe,
});
}
function safeGet(
target: Record<string, unknown>,
property: string,
): unknown {
try {
return Reflect.get(target, property);
} catch {
return undefined;
}
}
function randomIdFactory(
host: Record<string, unknown>,
): ((prefix: string) => string | null) | undefined {
const cryptoCandidate = safeGet(host, "crypto");
if (!cryptoCandidate || typeof cryptoCandidate !== "object") {
return undefined;
}
const randomUuid = safeGet(
cryptoCandidate as Record<string, unknown>,
"randomUUID",
);
if (typeof randomUuid !== "function") return undefined;
return (prefix) => {
try {
const value = Reflect.apply(randomUuid, cryptoCandidate, []);
if (typeof value !== "string") return null;
const candidate = `${prefix}.${value}`;
return isCacheInvalidationOpaqueIdentifier(candidate)
? candidate
: null;
} catch {
return null;
}
};
}
function broadcastFactory(
host: Record<string, unknown>,
):
| ((name: string) => BroadcastChannelFacade)
| undefined {
const Constructor = safeGet(host, "BroadcastChannel");
if (typeof Constructor !== "function") return undefined;
return (name) => {
const candidate = Reflect.construct(Constructor, [name]) as unknown;
if (!isNativeBroadcastChannel(candidate)) {
throw new TypeError("BroadcastChannel is incompatible.");
}
const listenerBindings = new Map<
BroadcastMessageListener,
(event: unknown) => void
>();
return Object.freeze({
postMessage(value: unknown) {
candidate.postMessage(value);
},
addEventListener(
_type: "message",
listener: BroadcastMessageListener,
) {
const bound = (event: unknown) => {
listener({
data:
event && typeof event === "object"
? safeGet(
event as Record<string, unknown>,
"data",
)
: undefined,
});
};
listenerBindings.set(listener, bound);
candidate.addEventListener("message", bound);
},
removeEventListener(
_type: "message",
listener: BroadcastMessageListener,
) {
const bound = listenerBindings.get(listener);
if (!bound) return;
listenerBindings.delete(listener);
candidate.removeEventListener("message", bound);
},
close() {
listenerBindings.clear();
candidate.close();
},
});
};
}
function isNativeBroadcastChannel(
value: unknown,
): value is NativeBroadcastChannel {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return ["postMessage", "addEventListener", "removeEventListener", "close"].every(
(method) => typeof safeGet(candidate, method) === "function",
);
}
function storageFacade(
host: Record<string, unknown>,
): StoragePulseFacade | undefined {
const candidate = safeGet(host, "localStorage");
if (!candidate || typeof candidate !== "object") return undefined;
const record = candidate as Record<string, unknown>;
const setItem = safeGet(record, "setItem");
const removeItem = safeGet(record, "removeItem");
if (typeof setItem !== "function" || typeof removeItem !== "function") {
return undefined;
}
return Object.freeze({
setItem(key, value) {
Reflect.apply(setItem, candidate, [key, value]);
},
removeItem(key) {
Reflect.apply(removeItem, candidate, [key]);
},
});
}
function storageEventTarget(
host: Record<string, unknown>,
): StorageEventTargetFacade | undefined {
const addEventListener = safeGet(host, "addEventListener");
const removeEventListener = safeGet(host, "removeEventListener");
if (
typeof addEventListener !== "function" ||
typeof removeEventListener !== "function"
) {
return undefined;
}
const bindings = new Map<
StoragePulseListener,
(event: unknown) => void
>();
return Object.freeze({
addEventListener(_type: "storage", listener: StoragePulseListener) {
const bound = (event: unknown) => {
if (!event || typeof event !== "object") {
listener({ key: null, newValue: null });
return;
}
const record = event as Record<string, unknown>;
const key = safeGet(record, "key");
const newValue = safeGet(record, "newValue");
listener({
key: typeof key === "string" ? key : null,
newValue: typeof newValue === "string" ? newValue : null,
});
};
bindings.set(listener, bound);
Reflect.apply(addEventListener, host, ["storage", bound]);
},
removeEventListener(
_type: "storage",
listener: StoragePulseListener,
) {
const bound = bindings.get(listener);
if (!bound) return;
bindings.delete(listener);
Reflect.apply(removeEventListener, host, ["storage", bound]);
},
});
}
@@ -0,0 +1,710 @@
import {
CACHE_INVALIDATION_PROTOCOL_VERSION,
CACHE_INVALIDATION_WIRE_LIMITS,
decodeCacheInvalidationWireEvent,
isCacheInvalidationOpaqueIdentifier,
isCacheInvalidationTopic,
parseCacheInvalidationWireEvent,
type CacheInvalidationParseFailureReason,
type CacheInvalidationTopicDefinition,
type CacheInvalidationWireEvent,
} from "../../contracts/cache-invalidation.ts";
export type CrossContextInvalidationStatus =
| "ACTIVE_BROADCAST"
| "ACTIVE_STORAGE_FALLBACK"
| "DEGRADED_LOCAL_ONLY"
| "CLOSED";
export type CrossContextInvalidationTransport =
| "BROADCAST"
| "STORAGE"
| "NONE";
export type CrossContextInvalidationOrdering = "NEXT" | "GAP";
export type CrossContextInvalidationDelivery = Readonly<{
event: CacheInvalidationWireEvent;
ordering: CrossContextInvalidationOrdering;
transport: Exclude<CrossContextInvalidationTransport, "NONE">;
}>;
export type CrossContextInvalidationObservationReason =
| CacheInvalidationParseFailureReason
| "BROADCAST_OPEN_FAILED"
| "BROADCAST_PUBLISH_FAILED"
| "CLOSED"
| "DELIVERED"
| "DUPLICATE"
| "HANDLER_FAILED"
| "OPENED"
| "PUBLISHED"
| "SELF_ECHO"
| "SEQUENCE_EXHAUSTED"
| "STALE"
| "STORAGE_CLEANUP_FAILED"
| "STORAGE_LISTENER_FAILED"
| "STORAGE_PUBLISH_FAILED";
/**
* Safe to project into diagnostics: it contains no event, topic, cache epoch,
* source identifier, storage value or native exception.
*/
export type CrossContextInvalidationObservation = Readonly<{
operation: "OPEN" | "PUBLISH" | "RECEIVE" | "CLOSE";
outcome: "ACCEPTED" | "DEGRADED" | "DROPPED" | "FAILED";
transport: CrossContextInvalidationTransport;
reason: CrossContextInvalidationObservationReason;
ordering?: CrossContextInvalidationOrdering;
}>;
export type CrossContextInvalidationPublishResult =
| Readonly<{
ok: true;
transport: Exclude<CrossContextInvalidationTransport, "NONE">;
}>
| Readonly<{
ok: false;
reason:
| "CLOSED"
| "INVALID_EVENT"
| "SEQUENCE_EXHAUSTED"
| "TRANSPORT_UNAVAILABLE";
}>;
export type BroadcastMessageEventFacade = Readonly<{ data: unknown }>;
export type BroadcastMessageListener = (
event: BroadcastMessageEventFacade,
) => void;
export type BroadcastChannelFacade = Readonly<{
postMessage(value: unknown): void;
addEventListener(
type: "message",
listener: BroadcastMessageListener,
): void;
removeEventListener(
type: "message",
listener: BroadcastMessageListener,
): void;
close(): void;
}>;
export type StoragePulseFacade = Readonly<{
setItem(key: string, value: string): void;
removeItem(key: string): void;
}>;
export type StoragePulseEvent = Readonly<{
key: string | null;
newValue: string | null;
}>;
export type StoragePulseListener = (event: StoragePulseEvent) => void;
export type StorageEventTargetFacade = Readonly<{
addEventListener(type: "storage", listener: StoragePulseListener): void;
removeEventListener(type: "storage", listener: StoragePulseListener): void;
}>;
export type BrowserCrossContextInvalidationDependencies = Readonly<{
channelName: string;
storagePulseKey: string;
sourceId: string;
sourceEpoch: string;
cacheEpoch: string;
topics: readonly CacheInvalidationTopicDefinition[];
createEventId(): string;
createBroadcastChannel?: (name: string) => BroadcastChannelFacade;
storage?: StoragePulseFacade;
storageEvents?: StorageEventTargetFacade;
nowEpochMilliseconds?: () => number;
eventTtlMs?: number;
dedupeCapacity?: number;
sourceCapacity?: number;
observe?: (observation: CrossContextInvalidationObservation) => void;
}>;
export type BrowserCrossContextInvalidation = Readonly<{
getStatus(): CrossContextInvalidationStatus;
publish(input: {
topic: string;
topicVersion: number;
}): CrossContextInvalidationPublishResult;
subscribe(
listener: (delivery: CrossContextInvalidationDelivery) => void,
): () => void;
close(): void;
}>;
type SeenEvent = Readonly<{ expiresAt: number }>;
type SourceHighWatermark = Readonly<{
sequence: number;
expiresAt: number;
}>;
const DEFAULT_EVENT_TTL_MS = 60_000;
const DEFAULT_DEDUPE_CAPACITY = 1_024;
const DEFAULT_SOURCE_CAPACITY = 256;
const MAX_DEDUPE_CAPACITY = 4_096;
const MAX_SOURCE_CAPACITY = 1_024;
const MAX_CHANNEL_NAME_LENGTH = 128;
const MAX_STORAGE_KEY_LENGTH = 256;
export function createBrowserCrossContextInvalidation(
dependencies: BrowserCrossContextInvalidationDependencies,
): BrowserCrossContextInvalidation {
const topicVersions = validateConfiguration(dependencies);
const now = dependencies.nowEpochMilliseconds ?? Date.now;
const eventTtlMs = dependencies.eventTtlMs ?? DEFAULT_EVENT_TTL_MS;
const dedupeCapacity =
dependencies.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY;
const sourceCapacity =
dependencies.sourceCapacity ?? DEFAULT_SOURCE_CAPACITY;
const listeners = new Set<
(delivery: CrossContextInvalidationDelivery) => void
>();
const seenEvents = new Map<string, SeenEvent>();
const sourceHighWatermarks = new Map<string, SourceHighWatermark>();
let closed = false;
let sequence = 0;
let broadcast: BroadcastChannelFacade | undefined;
let storageListenerInstalled = false;
let status: CrossContextInvalidationStatus = "DEGRADED_LOCAL_ONLY";
const receiveBroadcast: BroadcastMessageListener = (message) => {
receive(message.data, "BROADCAST");
};
const receiveStorage: StoragePulseListener = (event) => {
if (
closed ||
event.key !== dependencies.storagePulseKey ||
typeof event.newValue !== "string"
) {
return;
}
const parsed = decodeCacheInvalidationWireEvent(event.newValue, {
cacheEpoch: dependencies.cacheEpoch,
topicVersions,
nowEpochMilliseconds: safeNow(now),
});
acceptParsed(parsed, "STORAGE");
};
installStorageListener();
openBroadcast();
refreshStatus();
function installStorageListener(): void {
if (!dependencies.storageEvents) return;
try {
dependencies.storageEvents.addEventListener(
"storage",
receiveStorage,
);
storageListenerInstalled = true;
} catch {
observe({
operation: "OPEN",
outcome: "DEGRADED",
transport: "STORAGE",
reason: "STORAGE_LISTENER_FAILED",
});
}
}
function openBroadcast(): void {
if (!dependencies.createBroadcastChannel) return;
let candidate: BroadcastChannelFacade | undefined;
try {
candidate = dependencies.createBroadcastChannel(
dependencies.channelName,
);
candidate.addEventListener("message", receiveBroadcast);
broadcast = candidate;
observe({
operation: "OPEN",
outcome: "ACCEPTED",
transport: "BROADCAST",
reason: "OPENED",
});
} catch {
if (candidate) {
try {
candidate.removeEventListener("message", receiveBroadcast);
} catch {
// Opening still fails closed when listener cleanup is rejected.
}
try {
candidate.close();
} catch {
// Opening still falls back when provider cleanup is rejected.
}
}
observe({
operation: "OPEN",
outcome: "DEGRADED",
transport: "BROADCAST",
reason: "BROADCAST_OPEN_FAILED",
});
}
}
function refreshStatus(): void {
if (closed) {
status = "CLOSED";
} else if (broadcast) {
status = "ACTIVE_BROADCAST";
} else if (
dependencies.storage &&
dependencies.storageEvents &&
storageListenerInstalled
) {
status = "ACTIVE_STORAGE_FALLBACK";
} else {
status = "DEGRADED_LOCAL_ONLY";
}
}
function publish(input: {
topic: string;
topicVersion: number;
}): CrossContextInvalidationPublishResult {
if (closed) {
return Object.freeze({ ok: false, reason: "CLOSED" });
}
if (sequence >= Number.MAX_SAFE_INTEGER) {
observe({
operation: "PUBLISH",
outcome: "FAILED",
transport: "NONE",
reason: "SEQUENCE_EXHAUSTED",
});
return Object.freeze({
ok: false,
reason: "SEQUENCE_EXHAUSTED",
});
}
const emittedAt = safeNow(now);
let eventId: string;
try {
eventId = dependencies.createEventId();
} catch {
return invalidPublish();
}
const candidate: CacheInvalidationWireEvent = Object.freeze({
protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION,
eventId,
sourceId: dependencies.sourceId,
sourceEpoch: dependencies.sourceEpoch,
sequence: sequence + 1,
cacheEpoch: dependencies.cacheEpoch,
topic: input.topic,
topicVersion: input.topicVersion,
emittedAt,
expiresAt: emittedAt + eventTtlMs,
});
const parsed = parseCacheInvalidationWireEvent(candidate, {
cacheEpoch: dependencies.cacheEpoch,
topicVersions,
nowEpochMilliseconds: emittedAt,
});
if (!parsed.ok) return invalidPublish();
sequence = candidate.sequence;
pruneTracking(emittedAt);
if (broadcast) {
try {
broadcast.postMessage(candidate);
observe({
operation: "PUBLISH",
outcome: "ACCEPTED",
transport: "BROADCAST",
reason: "PUBLISHED",
});
return Object.freeze({
ok: true,
transport: "BROADCAST",
});
} catch {
observe({
operation: "PUBLISH",
outcome: "DEGRADED",
transport: "BROADCAST",
reason: "BROADCAST_PUBLISH_FAILED",
});
closeBroadcast();
refreshStatus();
}
}
return publishThroughStorage(candidate);
}
function invalidPublish(): CrossContextInvalidationPublishResult {
observe({
operation: "PUBLISH",
outcome: "FAILED",
transport: "NONE",
reason: "INVALID_ENVELOPE",
});
return Object.freeze({ ok: false, reason: "INVALID_EVENT" });
}
function publishThroughStorage(
event: CacheInvalidationWireEvent,
): CrossContextInvalidationPublishResult {
if (
!dependencies.storage ||
!dependencies.storageEvents ||
!storageListenerInstalled
) {
status = "DEGRADED_LOCAL_ONLY";
observe({
operation: "PUBLISH",
outcome: "DEGRADED",
transport: "NONE",
reason: "STORAGE_PUBLISH_FAILED",
});
return Object.freeze({
ok: false,
reason: "TRANSPORT_UNAVAILABLE",
});
}
const serialized = JSON.stringify(event);
try {
dependencies.storage.setItem(
dependencies.storagePulseKey,
serialized,
);
} catch {
status = "DEGRADED_LOCAL_ONLY";
observe({
operation: "PUBLISH",
outcome: "DEGRADED",
transport: "STORAGE",
reason: "STORAGE_PUBLISH_FAILED",
});
return Object.freeze({
ok: false,
reason: "TRANSPORT_UNAVAILABLE",
});
}
try {
dependencies.storage.removeItem(dependencies.storagePulseKey);
} catch {
// A fixed pulse key prevents unbounded retained keys. A later publish
// overwrites it with a unique event, so delivery succeeded even when
// best-effort cleanup did not.
observe({
operation: "PUBLISH",
outcome: "DEGRADED",
transport: "STORAGE",
reason: "STORAGE_CLEANUP_FAILED",
});
}
status = "ACTIVE_STORAGE_FALLBACK";
observe({
operation: "PUBLISH",
outcome: "ACCEPTED",
transport: "STORAGE",
reason: "PUBLISHED",
});
return Object.freeze({ ok: true, transport: "STORAGE" });
}
function receive(
input: unknown,
transport: Exclude<CrossContextInvalidationTransport, "NONE">,
): void {
if (closed) return;
const parsed = parseCacheInvalidationWireEvent(input, {
cacheEpoch: dependencies.cacheEpoch,
topicVersions,
nowEpochMilliseconds: safeNow(now),
});
acceptParsed(parsed, transport);
}
function acceptParsed(
parsed: ReturnType<typeof parseCacheInvalidationWireEvent>,
transport: Exclude<CrossContextInvalidationTransport, "NONE">,
): void {
if (closed) return;
if (!parsed.ok) {
observe({
operation: "RECEIVE",
outcome: "DROPPED",
transport,
reason: parsed.reason,
});
return;
}
const event = parsed.value;
if (
event.sourceId === dependencies.sourceId &&
event.sourceEpoch === dependencies.sourceEpoch
) {
observe({
operation: "RECEIVE",
outcome: "DROPPED",
transport,
reason: "SELF_ECHO",
});
return;
}
const currentTime = safeNow(now);
pruneTracking(currentTime);
if (seenEvents.has(event.eventId)) {
touchSeen(event.eventId, event.expiresAt);
observe({
operation: "RECEIVE",
outcome: "DROPPED",
transport,
reason: "DUPLICATE",
});
return;
}
touchSeen(event.eventId, event.expiresAt);
const sourceKey = `${event.sourceId}\u0000${event.sourceEpoch}`;
const previous = sourceHighWatermarks.get(sourceKey);
if (previous && event.sequence <= previous.sequence) {
touchSource(sourceKey, previous);
observe({
operation: "RECEIVE",
outcome: "DROPPED",
transport,
reason: "STALE",
});
return;
}
const ordering: CrossContextInvalidationOrdering =
(!previous && event.sequence > 1) ||
(previous !== undefined &&
event.sequence > previous.sequence + 1)
? "GAP"
: "NEXT";
touchSource(sourceKey, {
sequence: event.sequence,
expiresAt: event.expiresAt,
});
const delivery = Object.freeze({ event, ordering, transport });
for (const listener of [...listeners]) {
if (closed) return;
if (!listeners.has(listener)) continue;
try {
listener(delivery);
} catch {
observe({
operation: "RECEIVE",
outcome: "FAILED",
transport,
reason: "HANDLER_FAILED",
ordering,
});
}
}
observe({
operation: "RECEIVE",
outcome: "ACCEPTED",
transport,
reason: "DELIVERED",
ordering,
});
}
function touchSeen(eventId: string, expiresAt: number): void {
seenEvents.delete(eventId);
seenEvents.set(eventId, { expiresAt });
evictOldest(seenEvents, dedupeCapacity);
}
function touchSource(
sourceKey: string,
value: SourceHighWatermark,
): void {
sourceHighWatermarks.delete(sourceKey);
sourceHighWatermarks.set(sourceKey, value);
evictOldest(sourceHighWatermarks, sourceCapacity);
}
function pruneTracking(currentTime: number): void {
for (const [eventId, entry] of seenEvents) {
if (entry.expiresAt <= currentTime) seenEvents.delete(eventId);
}
for (const [sourceKey, entry] of sourceHighWatermarks) {
if (entry.expiresAt <= currentTime) {
sourceHighWatermarks.delete(sourceKey);
}
}
}
function subscribe(
listener: (delivery: CrossContextInvalidationDelivery) => void,
): () => void {
if (closed) return () => {};
listeners.add(listener);
let subscribed = true;
return () => {
if (!subscribed) return;
subscribed = false;
listeners.delete(listener);
};
}
function closeBroadcast(): void {
const current = broadcast;
broadcast = undefined;
if (!current) return;
try {
current.removeEventListener("message", receiveBroadcast);
} catch {
// Closing continues even when a provider rejects listener removal.
}
try {
current.close();
} catch {
// Closing is best-effort and remains idempotent.
}
}
function close(): void {
if (closed) return;
closed = true;
closeBroadcast();
if (storageListenerInstalled && dependencies.storageEvents) {
try {
dependencies.storageEvents.removeEventListener(
"storage",
receiveStorage,
);
} catch {
// Local closed state still prevents any late callback.
}
}
storageListenerInstalled = false;
listeners.clear();
seenEvents.clear();
sourceHighWatermarks.clear();
status = "CLOSED";
observe({
operation: "CLOSE",
outcome: "ACCEPTED",
transport: "NONE",
reason: "CLOSED",
});
}
function observe(
observation: CrossContextInvalidationObservation,
): void {
try {
dependencies.observe?.(Object.freeze({ ...observation }));
} catch {
// Transport behavior must not depend on diagnostics.
}
}
return Object.freeze({
getStatus: () => status,
publish,
subscribe,
close,
});
}
function validateConfiguration(
dependencies: BrowserCrossContextInvalidationDependencies,
): Readonly<Record<string, number>> {
if (
typeof dependencies.channelName !== "string" ||
dependencies.channelName.length < 1 ||
dependencies.channelName.length > MAX_CHANNEL_NAME_LENGTH ||
typeof dependencies.storagePulseKey !== "string" ||
dependencies.storagePulseKey.length < 1 ||
dependencies.storagePulseKey.length > MAX_STORAGE_KEY_LENGTH ||
!isCacheInvalidationOpaqueIdentifier(dependencies.sourceId) ||
!isCacheInvalidationOpaqueIdentifier(dependencies.sourceEpoch) ||
!isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch) ||
typeof dependencies.createEventId !== "function"
) {
throw new TypeError(
"Cross-context invalidation configuration is invalid.",
);
}
const eventTtlMs = dependencies.eventTtlMs ?? DEFAULT_EVENT_TTL_MS;
const dedupeCapacity =
dependencies.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY;
const sourceCapacity =
dependencies.sourceCapacity ?? DEFAULT_SOURCE_CAPACITY;
if (
!Number.isSafeInteger(eventTtlMs) ||
eventTtlMs < 1 ||
eventTtlMs >
CACHE_INVALIDATION_WIRE_LIMITS.maxEventTtlMs ||
!Number.isSafeInteger(dedupeCapacity) ||
dedupeCapacity < 1 ||
dedupeCapacity > MAX_DEDUPE_CAPACITY ||
!Number.isSafeInteger(sourceCapacity) ||
sourceCapacity < 1 ||
sourceCapacity > MAX_SOURCE_CAPACITY
) {
throw new TypeError(
"Cross-context invalidation bounds are invalid.",
);
}
if (!Array.isArray(dependencies.topics)) {
throw new TypeError(
"Cross-context invalidation topic registry is invalid.",
);
}
const topicVersions: Record<string, number> = Object.create(null);
for (const definition of dependencies.topics) {
if (
!definition ||
typeof definition !== "object" ||
!isCacheInvalidationTopic(definition.topic) ||
!Number.isSafeInteger(definition.topicVersion) ||
definition.topicVersion < 1 ||
Object.hasOwn(topicVersions, definition.topic)
) {
throw new TypeError(
"Cross-context invalidation topic registry is invalid.",
);
}
topicVersions[definition.topic] = definition.topicVersion;
}
if (Object.keys(topicVersions).length < 1) {
throw new TypeError(
"Cross-context invalidation requires an allowlisted topic.",
);
}
return Object.freeze(topicVersions);
}
function safeNow(now: () => number): number {
try {
const value = now();
return Number.isSafeInteger(value) && value >= 0 ? value : -1;
} catch {
return -1;
}
}
function evictOldest<Value>(
values: Map<string, Value>,
capacity: number,
): void {
while (values.size > capacity) {
const oldest = values.keys().next().value;
if (typeof oldest !== "string") return;
values.delete(oldest);
}
}
@@ -0,0 +1,23 @@
export {
createBrowserCrossContextInvalidation,
type BroadcastChannelFacade,
type BroadcastMessageEventFacade,
type BroadcastMessageListener,
type BrowserCrossContextInvalidation,
type BrowserCrossContextInvalidationDependencies,
type CrossContextInvalidationDelivery,
type CrossContextInvalidationObservation,
type CrossContextInvalidationObservationReason,
type CrossContextInvalidationOrdering,
type CrossContextInvalidationPublishResult,
type CrossContextInvalidationStatus,
type CrossContextInvalidationTransport,
type StorageEventTargetFacade,
type StoragePulseEvent,
type StoragePulseFacade,
type StoragePulseListener,
} from "./browser-cross-context-invalidation.ts";
export {
createBrowserCrossContextInvalidationFromHost,
type BrowserCrossContextHostDependencies,
} from "./browser-cross-context-host.ts";
@@ -0,0 +1,102 @@
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import {
projectDiagnosticRecord,
safeErrorKind,
type DiagnosticRecord,
type DiagnosticRecordInput,
} from "../../contracts/diagnostics.ts";
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
record() {},
});
export function createDiagnosticsAdapter(
options: Readonly<{
maxEntries?: number;
now?: () => number;
sink?: (record: DiagnosticRecord) => void;
}> = {},
) {
const maxEntries = Math.max(1, options.maxEntries ?? 100);
const entries: DiagnosticRecord[] = [];
const droppedReasons = new Map<string, number>();
function drop(reason: string) {
droppedReasons.set(reason, (droppedReasons.get(reason) ?? 0) + 1);
}
function record(input: DiagnosticRecordInput) {
try {
const projected = projectDiagnosticRecord(input, options.now);
if (!projected.success) {
drop(projected.reason);
return;
}
if (entries.length >= maxEntries) {
entries.shift();
drop("queue-full");
}
entries.push(projected.record);
try {
options.sink?.(projected.record);
} catch {
drop("sink-failure");
}
} catch {
drop("serialization-failure");
}
}
return Object.freeze({
record,
entries: () => structuredClone(entries) as readonly DiagnosticRecord[],
dropped: () => Object.freeze(Object.fromEntries(droppedReasons)),
});
}
type BootSafeContext = Readonly<{
kind?: string;
buildId?: string;
configSchemaVersion?: string;
supportReference?: string;
}>;
let lastBootEvidence:
| Readonly<{
diagnostic: DiagnosticRecord | null;
telemetry: Readonly<Record<string, unknown>> | null;
}>
| undefined;
export function recordBootFailure(
error: unknown,
safe: BootSafeContext,
now: () => number = Date.now,
) {
const errorKind =
typeof safe.kind === "string" ? safe.kind : safeErrorKind(error);
const attributes = {
error_kind: errorKind,
build_id: safe.buildId ?? "unknown",
config_schema_version: safe.configSchemaVersion ?? "unknown",
};
const diagnostic = projectDiagnosticRecord(
{
level: "error",
eventId: "app.boot.failed",
context: attributes,
},
now,
);
const telemetry = projectTelemetryEvent("app.boot.failed", attributes, now);
lastBootEvidence = Object.freeze({
diagnostic: diagnostic.success ? diagnostic.record : null,
telemetry: telemetry.success ? telemetry.event : null,
});
return lastBootEvidence;
}
export function getLastBootEvidence() {
return lastBootEvidence ? structuredClone(lastBootEvidence) : undefined;
}
+160
View File
@@ -0,0 +1,160 @@
/**
* §7.9–§7.10. Common bounded body reader.
*
* `bounded-json.ts` stays the V2 reader for already-migrated callers; this
* module adds the byte-level primitives execution V3 needs: a raw bounded read,
* a strict media-type check and the `NONE` one-byte probe.
*/
export type BoundedBytesOutcome =
| Readonly<{ ok: true; bytes: Uint8Array }>
| Readonly<{ ok: false; code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE" }>;
export type BodyProbeOutcome =
| Readonly<{ ok: true; present: boolean }>
| Readonly<{ ok: false; code: "RESPONSE_STREAM_FAILURE" }>;
/** Essence match: `application/json` or any `*+json` subtype. */
export function isJsonMediaType(headerValue: string | null): boolean {
if (!headerValue) return false;
const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? "";
return essence === "application/json" || essence.endsWith("+json");
}
export function declaredContentLength(response: Response): number | null {
const raw = response.headers.get("content-length");
if (raw === null) return null;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : null;
}
export async function readBoundedBytes(
response: Response,
maximumBytes: number,
): Promise<BoundedBytesOutcome> {
const declared = declaredContentLength(response);
if (declared !== null && declared > maximumBytes) {
await cancelBody(response);
return failure("RESPONSE_TOO_LARGE");
}
if (!response.body) {
return Object.freeze({ ok: true as const, bytes: new Uint8Array(0) });
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
if (!next.value) continue;
total += next.value.byteLength;
if (total > maximumBytes) {
await reader.cancel().catch(() => {});
return failure("RESPONSE_TOO_LARGE");
}
chunks.push(next.value);
}
} catch {
await reader.cancel().catch(() => {});
return failure("RESPONSE_STREAM_FAILURE");
} finally {
try {
reader.releaseLock();
} catch {
// A cancelled reader has already released its lock.
}
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return Object.freeze({ ok: true as const, bytes });
}
/**
* §7.10 `NONE`. A declared positive length is an immediate violation. Otherwise
* at most one byte is probed: the descriptor does not permit an unexpected
* body, so the runtime never drains an arbitrary amount to find out.
*/
export async function probeForbiddenBody(
response: Response,
): Promise<BodyProbeOutcome> {
const declared = declaredContentLength(response);
if (declared !== null && declared > 0) {
await cancelBody(response);
return Object.freeze({ ok: true as const, present: true });
}
if (!response.body) {
return Object.freeze({ ok: true as const, present: false });
}
const reader = response.body.getReader();
try {
const next = await reader.read();
if (next.done || !next.value || next.value.byteLength === 0) {
return Object.freeze({ ok: true as const, present: false });
}
await reader.cancel().catch(() => {});
return Object.freeze({ ok: true as const, present: true });
} catch {
await reader.cancel().catch(() => {});
return Object.freeze({
ok: false as const,
code: "RESPONSE_STREAM_FAILURE" as const,
});
} finally {
try {
reader.releaseLock();
} catch {
// Already released by cancel().
}
}
}
export type DecodedJson =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; code: "UTF8_INVALID" | "JSON_INVALID" }>;
export function decodeJsonBytes(bytes: Uint8Array): DecodedJson {
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return Object.freeze({ ok: false as const, code: "UTF8_INVALID" as const });
}
try {
return Object.freeze({ ok: true as const, value: JSON.parse(text) });
} catch {
return Object.freeze({ ok: false as const, code: "JSON_INVALID" as const });
}
}
export function isEffectivelyEmpty(bytes: Uint8Array): boolean {
if (bytes.byteLength === 0) return true;
for (const byte of bytes) {
// Space, tab, LF, CR are the only permitted "empty" filler.
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
return false;
}
}
return true;
}
async function cancelBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// Cancelling an already-settled body is not itself a failure.
}
}
function failure(
code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE",
): BoundedBytesOutcome {
return Object.freeze({ ok: false as const, code });
}
+50
View File
@@ -0,0 +1,50 @@
export type BoundedJsonResult =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; code: "RESPONSE_BODY_LIMIT" | "MALFORMED_JSON" }>;
export async function readBoundedJson(
response: Response,
maxBytes: number,
): Promise<BoundedJsonResult> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel();
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
}
if (!response.body) return { ok: false, code: "MALFORMED_JSON" };
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
total += next.value.byteLength;
if (total > maxBytes) {
await reader.cancel();
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
}
chunks.push(next.value);
}
} catch {
return { ok: false, code: "MALFORMED_JSON" };
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return {
ok: true,
value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
};
} catch {
return { ok: false, code: "MALFORMED_JSON" };
}
}
+944
View File
@@ -0,0 +1,944 @@
import { systemClock } from "../platform/system-clock.ts";
import { getApiOperation } from "../../contracts/api-operations.ts";
import {
createFailure as failure,
kindForStatus as statusKind,
safeValidationIssues,
} from "../../contracts/errors.ts";
import { mapOperationPayload } from "./resource-mapper.ts";
import { retryDelay, shouldRetry } from "./retry-policy.ts";
import {
validateEnvelope,
validateOperationPayload,
validateOperationRequest,
} from "./schema-registry.ts";
import { buildRequestTarget } from "./request-builder.ts";
import {
attemptBucket,
durationBucket,
statusGroup,
} from "../../contracts/diagnostics.ts";
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
import type { ClockPort } from "../../application/ports/clock-port.ts";
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
import type { ApiOperation } from "../../contracts/api-operations.ts";
import type { ApiFailure } from "../../contracts/errors.ts";
import type { OperationRequestInput } from "./request-builder.ts";
import { readBoundedJson } from "./bounded-json.ts";
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
import {
createRestProviderProfile,
resolveRestSecurityProfiles,
REST_AUTH_PROFILES,
REST_CSRF_PROFILES,
type RestAuthProfile,
type RestCsrfProfile,
type RestProviderProfile,
} from "../../contracts/rest-profiles.ts";
type HttpAuthSession = Pick<
AuthSessionPort,
"getState" | "credentialPatch" | "recover" | "onUnauthenticated"
>;
const noAuthSession: HttpAuthSession = Object.freeze({
getState: () => "integration-failed",
credentialPatch: async () => {
throw new TypeError("Auth session is not installed");
},
recover: async () => "no-session",
onUnauthenticated: () => {},
} satisfies HttpAuthSession);
export type HttpFailure = ApiFailure;
export type Scheduler = Readonly<{
setTimeout(callback: () => void, milliseconds: number): unknown;
clearTimeout(handle: unknown): void;
}>;
export type HttpResult =
| Readonly<{
ok: true;
value: unknown;
meta: Readonly<Record<string, string>>;
}>
| Readonly<{ ok: false; error: HttpFailure }>;
type SchemaValidator = (
schemaId: string,
value: unknown,
) =>
| Readonly<{ success: true; data: unknown }>
| Readonly<{ success: false }>;
export type HttpClientDependencies = Readonly<{
baseUrl: string;
fetcher?: typeof fetch;
authSession?: AuthSessionPort;
clock?: ClockPort;
random?: () => number;
validatePayload?: SchemaValidator;
validateRequest?: SchemaValidator;
validatePath?: SchemaValidator;
mapPayload?: (
operationId: string,
payload: unknown,
) => MappingResult<unknown>;
idempotencyKeyFactory?: () => string;
timeoutMs?: number;
maxRetryAttempts?: number;
scheduler?: Scheduler;
getOperation?: typeof getApiOperation;
diagnostics?: DiagnosticsPort;
telemetry?: TelemetryPort;
correlationIdFactory?: () => string;
providerProfile?: RestProviderProfile;
authProfiles?: Readonly<Record<string, RestAuthProfile>>;
csrfProfiles?: Readonly<Record<string, RestCsrfProfile>>;
maxCumulativeSleepMs?: number;
}>;
export type LegacyHttpInput = Readonly<{
body?: unknown;
routeId?: string;
pathParams?: Readonly<Record<string, string | number>>;
searchParams?: unknown;
signal?: AbortSignal;
idempotencyKey?: string;
correlationId?: string;
}>;
export type HttpClient = Readonly<{
execute(
request: string | OperationRequestInput,
legacyInput?: LegacyHttpInput,
): Promise<HttpResult>;
}>;
export function createHttpClient(
dependencies: HttpClientDependencies,
): HttpClient {
const fetcher = dependencies.fetcher ?? fetch;
const authSession = dependencies.authSession ?? noAuthSession;
const clock = dependencies.clock ?? systemClock;
const random = dependencies.random ?? Math.random;
const validatePayload =
dependencies.validatePayload ?? validateOperationPayload;
const validateRequest =
dependencies.validateRequest ?? validateOperationRequest;
const validatePath = dependencies.validatePath ?? validateRequest;
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
const maxCumulativeSleepMs =
dependencies.maxCumulativeSleepMs ?? defaultTimeoutMs;
const selectOperation = dependencies.getOperation ?? getApiOperation;
const diagnostics = dependencies.diagnostics;
const telemetry = dependencies.telemetry;
const correlationIdFactory =
dependencies.correlationIdFactory ??
(() => `request-${Math.floor(random() * 1_000_000).toString(36)}`);
const scheduler =
dependencies.scheduler ??
({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
),
} satisfies Scheduler);
async function execute(
request: string | OperationRequestInput,
legacyInput: LegacyHttpInput = {},
): Promise<HttpResult> {
const input =
typeof request === "string"
? {
operationId: request,
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
pathParams: legacyInput.pathParams,
searchParams: legacyInput.searchParams,
body: legacyInput.body,
signal: legacyInput.signal,
idempotencyKey: legacyInput.idempotencyKey,
correlationId: legacyInput.correlationId,
}
: request;
const startedAt = clock.now();
let correlationId: string;
try {
correlationId = correlationIdValue(
input.correlationId ?? correlationIdFactory(),
);
} catch {
correlationId = "client-generated";
}
let operation: ApiOperation;
try {
operation = selectOperation(input.operationId);
} catch {
return {
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
code: "OPERATION_NOT_REGISTERED",
}),
};
}
const totalDeadlineMs = operation.timeoutMs ?? defaultTimeoutMs;
const deadlineAt = startedAt + totalDeadlineMs;
let physicalAttemptCount = 0;
function finalize(
outcome: HttpResult,
outcomeKind: "success" | "recovered" | "failed" | "aborted",
): HttpResult {
const error = outcome.ok ? undefined : outcome.error;
const context = {
route_id: input.routeId,
operation_id: input.operationId,
correlation_id: correlationId,
outcome: outcomeKind,
error_kind: error?.kind ?? "NONE",
http_status_group: statusGroup(
error?.httpStatus ??
(outcome.ok ? Number(outcome.meta.httpStatus) : undefined),
),
attempt_count_bucket: attemptBucket(
error?.attemptCount ?? Math.max(1, physicalAttemptCount),
),
duration_bucket: durationBucket(clock.now() - startedAt),
};
try {
diagnostics?.record({
level: error ? "warn" : "info",
eventId: "http.request.completed",
context,
});
} catch {
// Diagnostics cannot change the HTTP result.
}
if (error && outcomeKind !== "aborted") {
try {
telemetry?.emit("api.request.failed", {
error_kind: context.error_kind,
http_status_group: context.http_status_group,
attempt_count_bucket: context.attempt_count_bucket,
route_id: context.route_id,
operation_id: context.operation_id,
duration_bucket: context.duration_bucket,
});
} catch {
// Telemetry cannot change the HTTP result.
}
}
return outcome;
}
let logicalIdempotencyKey: string | undefined;
try {
logicalIdempotencyKey =
operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory()
: undefined;
} catch {
return finalize(
{
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
}),
},
"failed",
);
}
let retryCount = 0;
let recoveryUsed = false;
let cumulativeSleepMs = 0;
while (true) {
const attempt = physicalAttemptCount;
physicalAttemptCount += 1;
let outcome: HttpResult;
try {
outcome = await performAttempt({
operation,
input,
attempt,
idempotencyKey: logicalIdempotencyKey,
deadlineAt,
correlationId,
});
} catch {
outcome = {
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, attempt, {
code: "HTTP_EXECUTION_CONTRACT_VIOLATION",
}),
};
}
if (outcome.ok) {
return finalize(
outcome,
retryCount > 0 || recoveryUsed ? "recovered" : "success",
);
}
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
recoveryUsed = true;
if (physicalAttemptCount >= maxRetryAttempts + 1) {
authSession.onUnauthenticated();
return finalize(outcome, "failed");
}
let recovered: Awaited<ReturnType<typeof recoverSession>>;
try {
recovered = await withinLogicalDeadline(
recoverSession(authSession, operation, outcome.error),
deadlineAt,
input.signal,
);
} catch (error) {
return finalize(
{
ok: false,
error: failure(
error instanceof LogicalDeadlineError
? "REQUEST_TIMEOUT"
: "REQUEST_ABORTED",
operation.operationId,
attempt,
{
code:
error instanceof LogicalDeadlineError
? "OPERATION_DEADLINE_EXCEEDED"
: "REQUEST_ABORTED",
},
),
},
error instanceof LogicalDeadlineError ? "failed" : "aborted",
);
}
if (!recovered.ok) return finalize(recovered, "failed");
if (operation.idempotency === "none") {
return finalize(
{
ok: false,
error: {
...outcome.error,
retryable: false,
action: "retry",
},
},
"failed",
);
}
continue;
}
if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated();
return finalize(outcome, "failed");
}
if (
!shouldRetry(
operation,
outcome.error,
retryCount,
maxRetryAttempts,
)
) {
return finalize(
outcome,
outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed",
);
}
const delay = retryDelay(outcome.error, retryCount, random, clock.now());
retryCount += 1;
cumulativeSleepMs += delay;
if (
clock.now() + delay >= deadlineAt ||
cumulativeSleepMs > maxCumulativeSleepMs
) {
return finalize(
{
ok: false,
error: failure("REQUEST_TIMEOUT", input.operationId, attempt, {
code: "OPERATION_DEADLINE_EXCEEDED",
}),
},
"failed",
);
}
try {
await clock.sleep(delay, input.signal);
} catch {
return finalize(
{
ok: false,
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED",
}),
},
"aborted",
);
}
}
}
async function performAttempt(
context: Readonly<{
operation: ApiOperation;
input: OperationRequestInput;
attempt: number;
idempotencyKey?: string;
deadlineAt: number;
correlationId: string;
}>,
): Promise<HttpResult> {
const {
operation,
input,
attempt,
idempotencyKey,
deadlineAt,
correlationId,
} = context;
if (clock.now() >= deadlineAt) {
return {
ok: false,
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
code: "OPERATION_DEADLINE_EXCEEDED",
}),
};
}
let parsedSearch: unknown = {};
let parsedBody: unknown;
let parsedPath: Readonly<Record<string, string | number>> =
input.pathParams ?? {};
if (operation.pathSchema) {
const pathValidation = validatePath(
operation.pathSchema,
input.pathParams ?? {},
);
if (
!pathValidation.success ||
!isPathParameterRecord(pathValidation.data)
) {
return {
ok: false,
error: failure(
"VALIDATION_REJECTED",
operation.operationId,
attempt,
{ code: "PATH_SCHEMA_INVALID" },
),
};
}
parsedPath = pathValidation.data;
}
const requestValue =
operation.requestSource === "search"
? input.searchParams ?? {}
: operation.requestSource === "body"
? input.body
: {};
if (operation.requestSource !== "none") {
const requestValidation = validateRequest(
operation.requestSchema,
requestValue,
);
if (!requestValidation.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: "REQUEST_SCHEMA_INVALID",
}),
};
}
if (operation.requestSource === "search") {
parsedSearch = requestValidation.data;
} else {
parsedBody = requestValidation.data;
}
}
let target: ReturnType<typeof buildRequestTarget>;
let provider: RestProviderProfile | null = null;
let security:
| ReturnType<typeof resolveRestSecurityProfiles>
| undefined;
try {
provider =
dependencies.providerProfile ??
createRestProviderProfile(
operation.providerId ?? "LEGACY_API",
dependencies.baseUrl,
["omit", "same-origin"],
);
if (
operation.contractVersion === 2 &&
operation.providerId !== provider.providerId
) {
throw new TypeError("REST provider binding mismatch.");
}
if (operation.contractVersion === 2) {
security = resolveRestSecurityProfiles(
operation,
provider,
dependencies.authProfiles ?? REST_AUTH_PROFILES,
dependencies.csrfProfiles ?? REST_CSRF_PROFILES,
);
}
target = buildRequestTarget(
provider.baseUrl,
operation,
parsedPath,
parsedSearch,
);
} catch {
target = { success: false, code: "BASE_URL_INVALID" };
}
if (!target.success || !provider) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: target.success ? "BASE_URL_INVALID" : target.code,
}),
};
}
const controller = new AbortController();
let timedOut = false;
const remainingMs = Math.max(1, deadlineAt - clock.now());
const timeout = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, remainingMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
if (input.signal?.aborted) onExternalAbort();
try {
const headers = new Headers({
Accept: operation.responseMediaTypes?.join(", ") ?? "application/json",
"X-Correlation-ID": correlationIdValue(correlationId),
});
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (operation.auth === "external-session") {
let sessionState: ReturnType<HttpAuthSession["getState"]>;
try {
sessionState = authSession.getState();
} catch {
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
attempt,
{ code: "AUTH_STATE_FAILED" },
),
};
}
if (sessionState === "unauthenticated") {
return {
ok: false,
error: failure("AUTH_REQUIRED", operation.operationId, attempt, {
code: "AUTH_REQUIRED",
}),
};
}
if (sessionState !== "authenticated") {
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
attempt,
{ code: "AUTH_SESSION_UNAVAILABLE" },
),
};
}
try {
const patch = await authSession.credentialPatch({
origin: target.url.origin,
method: operation.method,
operationId: operation.operationId,
});
for (const [name, value] of Object.entries(patch.headers)) {
const normalized = name.toLowerCase();
const allowedHeaders =
security?.auth.allowedCredentialHeaders ??
(["authorization", "x-csrf-token"] as const);
if (!allowedHeaders.includes(normalized as never)) {
throw new TypeError("Credential patch contains a forbidden header");
}
headers.set(normalized, value);
}
} catch {
return {
ok: false,
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
code: "AUTH_ATTACH_FAILED",
}),
};
}
}
const request = new Request(target.url, {
method: operation.method,
headers,
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
signal: controller.signal,
credentials: security?.auth.credentials ?? "same-origin",
cache: "no-store",
redirect: provider.redirect,
referrerPolicy: provider.referrerPolicy,
});
const response = await fetcher(request);
return await parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
clock.now(),
);
} catch {
if (timedOut) {
return {
ok: false,
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
code: "REQUEST_TIMEOUT",
}),
};
}
if (controller.signal.aborted || input.signal?.aborted) {
const externalReason = input.signal?.reason;
if (externalReason === "timeout") {
return {
ok: false,
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
code: "REQUEST_TIMEOUT",
}),
};
}
if (
externalReason !== undefined &&
!["navigation", "user", "superseded"].includes(String(externalReason))
) {
return {
ok: false,
error: failure("UNKNOWN_FAILURE", operation.operationId, attempt, {
code: "EXTERNAL_ABORT_UNRESOLVED",
}),
};
}
return {
ok: false,
error: failure("REQUEST_ABORTED", operation.operationId, attempt, {
code: "REQUEST_ABORTED",
}),
};
}
return {
ok: false,
error: failure("NETWORK_UNREACHABLE", operation.operationId, attempt, {
code: "NETWORK_UNREACHABLE",
}),
};
} finally {
scheduler.clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort);
}
}
return Object.freeze({ execute });
function withinLogicalDeadline<Value>(
promise: Promise<Value>,
deadlineAt: number,
externalSignal: AbortSignal | undefined,
): Promise<Value> {
const remaining = deadlineAt - clock.now();
if (remaining <= 0) return Promise.reject(new LogicalDeadlineError());
return new Promise<Value>((resolve, reject) => {
let settled = false;
const timeout = scheduler.setTimeout(
() => settle(() => reject(new LogicalDeadlineError())),
remaining,
);
const onAbort = () =>
settle(() => reject(new DOMException("Aborted", "AbortError")));
externalSignal?.addEventListener("abort", onAbort, { once: true });
const settle = (complete: () => void) => {
if (settled) return;
settled = true;
scheduler.clearTimeout(timeout);
externalSignal?.removeEventListener("abort", onAbort);
complete();
};
if (externalSignal?.aborted) {
onAbort();
return;
}
promise.then(
(value) => settle(() => resolve(value)),
(error: unknown) => settle(() => reject(error)),
);
});
}
}
class LogicalDeadlineError extends Error {}
async function parseResponse(
response: Response,
operation: ApiOperation,
attempt: number,
validatePayload: SchemaValidator,
mapPayload: (
operationId: string,
payload: unknown,
) => MappingResult<unknown>,
now: number,
): Promise<HttpResult> {
const contentType = mediaType(response.headers.get("content-type"));
const acceptedMedia = operation.responseMediaTypes ?? ["application/json"];
if (!contentType || !acceptedMedia.includes(contentType)) {
return {
ok: false,
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
code: "CONTENT_TYPE_MISMATCH",
httpStatus: response.status,
}),
};
}
const decoded = await readBoundedJson(
response,
operation.maxResponseBytes ?? 1_048_576,
);
if (!decoded.ok) {
return {
ok: false,
error: failure(decoded.code, operation.operationId, attempt, {
code: decoded.code,
httpStatus: response.status,
}),
};
}
const envelope = decoded.value;
const envelopeValidation = validateEnvelope(envelope);
if (!envelopeValidation.success) {
return {
ok: false,
error: failure(
response.ok ? "ENVELOPE_MISMATCH" : statusKind(response.status),
operation.operationId,
attempt,
{
code: response.ok ? "ENVELOPE_MISMATCH" : "HTTP_FAILURE",
httpStatus: response.status,
},
),
};
}
const envelopeRecord = envelopeValidation.data as Record<string, unknown>;
const successStatus = operation.successStatuses
? operation.successStatuses.includes(response.status)
: response.ok;
if (successStatus && envelopeRecord.success === true && "data" in envelopeRecord) {
const payload = validatePayload(operation.responseSchema, envelopeRecord.data);
if (!payload.success) {
return {
ok: false,
error: failure("SCHEMA_MISMATCH", operation.operationId, attempt, {
code: "SCHEMA_MISMATCH",
httpStatus: response.status,
}),
};
}
try {
const mapped = mapPayload(operation.operationId, payload.data);
if (!mapped.ok) {
return {
ok: false,
error: failure(
"MAPPING_CONTRACT_VIOLATION",
operation.operationId,
attempt,
{
code: mapped.code,
httpStatus: response.status,
},
),
};
}
return {
ok: true,
value: mapped.value,
meta: {
...safeMeta(envelopeRecord.meta),
httpStatus: String(response.status),
},
};
} catch {
return {
ok: false,
error: failure(
"MAPPING_CONTRACT_VIOLATION",
operation.operationId,
attempt,
{
code: "MAPPING_CONTRACT_VIOLATION",
httpStatus: response.status,
},
),
};
}
}
if (successStatus !== response.ok || (successStatus && envelopeRecord.success !== true)) {
return {
ok: false,
error: failure("ENVELOPE_MISMATCH", operation.operationId, attempt, {
code: "STATUS_ENVELOPE_MISMATCH",
httpStatus: response.status,
}),
};
}
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? (envelopeRecord.error as Record<string, unknown>)
: {};
return {
ok: false,
error: failure(kind, operation.operationId, attempt, {
code: safeBackendCode(envelope),
httpStatus: response.status,
requestId: safeMeta(envelopeRecord.meta).requestId,
traceId: safeMeta(envelopeRecord.meta).traceId,
retryAfterMs:
response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter, now)
: undefined,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: undefined,
}),
};
}
async function recoverSession(
authSession: HttpAuthSession,
operation: ApiOperation,
originalFailure: HttpFailure,
): Promise<
Readonly<{ ok: true }> | Readonly<{ ok: false; error: HttpFailure }>
> {
try {
const result = await authSession.recover();
if (result === "restored") return { ok: true };
if (result === "no-session") {
authSession.onUnauthenticated();
return {
ok: false,
error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount - 1, {
code: "AUTH_REQUIRED",
httpStatus: 401,
}),
};
}
} catch {
// Normalized below.
}
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
originalFailure.attemptCount - 1,
{ code: "AUTH_RECOVERY_FAILED" },
),
};
}
function safeBackendCode(envelope: unknown): string {
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
const error = (envelope as Record<string, unknown>).error;
if (!error || typeof error !== "object") return "HTTP_FAILURE";
const code = (error as Record<string, unknown>).code;
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code)
? code
: "HTTP_FAILURE";
}
function safeMeta(meta: unknown): Record<string, string> {
if (!meta || typeof meta !== "object") return {};
const metaRecord = meta as Record<string, unknown>;
return {
...(typeof metaRecord.requestId === "string"
? safeIdentifier(metaRecord.requestId, "requestId")
: {}),
...(typeof metaRecord.traceId === "string"
? safeIdentifier(metaRecord.traceId, "traceId")
: {}),
};
}
function parseRetryAfterHeader(value: string, now: number): number | undefined {
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : undefined;
}
function safeIdentifier(
value: string,
property: "requestId" | "traceId",
): Record<string, string> {
return /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? { [property]: value } : {};
}
function mediaType(value: string | null): string | null {
if (!value) return null;
const selected = value.split(";", 1)[0]?.trim().toLowerCase();
return selected && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(selected)
? selected
: null;
}
function correlationIdValue(value: string | undefined): string {
return value && /^[A-Za-z0-9._:-]{1,128}$/.test(value)
? value
: "client-generated";
}
function isPathParameterRecord(
value: unknown,
): value is Readonly<Record<string, string | number>> {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.values(value).every(
(item) => typeof item === "string" || typeof item === "number",
)
);
}
+288
View File
@@ -0,0 +1,288 @@
import {
HTTP_EXECUTION_CEILINGS,
type InstalledHttpContract,
} from "../../contracts/external-contract-runtime.ts";
/**
* §7.4–§7.7. Descriptor-driven request projection.
*
* Nothing here re-derives operation semantics. The package descriptor supplies
* path values, query entry order and the body value; this module only encodes,
* bounds and re-verifies them.
*/
export type CredentialPatchOutcome =
| Readonly<{
kind: "READY";
headers: Readonly<Record<string, string>>;
credentials: RequestCredentials;
}>
| Readonly<{ kind: "UNAUTHENTICATED" }>
| Readonly<{ kind: "UNAVAILABLE" }>
| Readonly<{ kind: "SCOPE_FENCED" }>;
/** §7.7. The complete set of headers a credential bridge may contribute. */
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
"authorization",
"x-csrf-token",
"x-tenant-context",
]);
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
"host",
"origin",
"referer",
"cookie",
"content-length",
"connection",
"transfer-encoding",
"upgrade",
]);
export type RequestProjectionFailure =
| "PROJECTION_RUNTIME_FAILURE"
| "PROJECTION_INVALID"
| "PATH_PLACEHOLDER_MISSING"
| "PATH_VALUE_INVALID"
| "QUERY_TOO_LARGE"
| "URL_TOO_LARGE"
| "URL_ORIGIN_ESCAPED"
| "REQUEST_BODY_TOO_LARGE"
| "REQUEST_BODY_UNENCODABLE"
| "REQUEST_BODY_UNEXPECTED";
export type ProjectedRequest = Readonly<{
url: string;
method: string;
bodyBytes: Uint8Array | null;
}>;
export type RequestProjectionOutcome =
| Readonly<{ ok: true; request: ProjectedRequest }>
| Readonly<{ ok: false; failure: RequestProjectionFailure }>;
const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9_]*)\}|:([A-Za-z][A-Za-z0-9_]*)/g;
const encoder = new TextEncoder();
/**
* §7.5. Built from the validated base URL and the descriptor's named values.
* Segments are encoded exactly once; a raw slash inside a value is rejected
* rather than silently creating a new path segment.
*/
export function projectRequest<Input, WireOutput, Problem>(
installed: InstalledHttpContract<Input, WireOutput, Problem>,
input: Input,
baseUrl: string,
): RequestProjectionOutcome {
try {
return projectRequestChecked(installed, input, baseUrl);
} catch {
return failure("PROJECTION_RUNTIME_FAILURE");
}
}
function projectRequestChecked<Input, WireOutput, Problem>(
installed: InstalledHttpContract<Input, WireOutput, Problem>,
input: Input,
baseUrl: string,
): RequestProjectionOutcome {
const contract = installed.contract;
const projection = contract.projectRequest(input);
if (!isValidProjection(projection)) return failure("PROJECTION_INVALID");
let missing = false;
let invalid = false;
const path = contract.pathTemplate.replace(
PLACEHOLDER,
(_match, braced?: string, colon?: string) => {
const name = braced ?? colon ?? "";
const value = projection.pathValues[name];
if (typeof value !== "string" || value.length === 0) {
missing = true;
return "";
}
if (value.includes("/") || value.includes("\\")) {
invalid = true;
return "";
}
return encodeURIComponent(value);
},
);
if (missing) return failure("PATH_PLACEHOLDER_MISSING");
if (invalid) return failure("PATH_VALUE_INVALID");
const base = new URL(baseUrl);
const url = new URL(path.replace(/^\/+/, ""), base);
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
return failure("URL_ORIGIN_ESCAPED");
}
const search = new URLSearchParams();
for (const [key, value] of projection.queryEntries) {
search.append(key, value);
}
const encodedQuery = search.toString();
if (
encoder.encode(encodedQuery).byteLength >
HTTP_EXECUTION_CEILINGS.encodedQueryBytes
) {
return failure("QUERY_TOO_LARGE");
}
url.search = encodedQuery;
if (encoder.encode(url.href).byteLength > HTTP_EXECUTION_CEILINGS.finalUrlBytes) {
return failure("URL_TOO_LARGE");
}
let bodyBytes: Uint8Array | null = null;
if (contract.requestBody === "JSON") {
let encoded: string;
try {
encoded = JSON.stringify(projection.body);
} catch {
return failure("REQUEST_BODY_UNENCODABLE");
}
if (typeof encoded !== "string") {
return failure("REQUEST_BODY_UNENCODABLE");
}
bodyBytes = encoder.encode(encoded);
if (bodyBytes.byteLength > installed.frontend.requestByteLimit) {
return failure("REQUEST_BODY_TOO_LARGE");
}
} else if (projection.body !== null && projection.body !== undefined) {
return failure("REQUEST_BODY_UNEXPECTED");
}
return Object.freeze({
ok: true as const,
request: Object.freeze({
url: url.href,
method: contract.method,
bodyBytes,
}),
});
}
function isValidProjection(
value: unknown,
): value is ReturnType<
InstalledHttpContract<unknown, unknown, unknown>["contract"]["projectRequest"]
> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Record<string, unknown>;
const keys = Object.keys(candidate).sort();
if (keys.join("|") !== "body|pathValues|queryEntries") return false;
const pathValues = candidate.pathValues;
if (!pathValues || typeof pathValues !== "object" || Array.isArray(pathValues)) {
return false;
}
const pathPrototype = Object.getPrototypeOf(pathValues);
if (pathPrototype !== Object.prototype && pathPrototype !== null) return false;
const pathEntries = Object.entries(pathValues as Record<string, unknown>);
if (pathEntries.length > 32) return false;
for (const [key, pathValue] of pathEntries) {
if (
!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) ||
typeof pathValue !== "string" ||
pathValue.length === 0 ||
encoder.encode(pathValue).byteLength > HTTP_EXECUTION_CEILINGS.pathTemplateBytes
) {
return false;
}
}
const queryEntries = candidate.queryEntries;
if (!Array.isArray(queryEntries) || queryEntries.length > 256) return false;
for (const entry of queryEntries) {
if (
!Array.isArray(entry) ||
entry.length !== 2 ||
typeof entry[0] !== "string" ||
typeof entry[1] !== "string"
) {
return false;
}
}
return true;
}
export type FinalInvariantInput = Readonly<{
request: ProjectedRequest;
expectedMethod: string;
baseUrl: string;
init: RequestInit;
headers: Readonly<Record<string, string>>;
requestByteLimit: number;
deadlineRemainingMs: number;
scopeIsCurrent: boolean;
}>;
export type FinalInvariantFailure =
| "METHOD_CHANGED"
| "URL_NOT_ALLOWED"
| "REDIRECT_MODE_INVALID"
| "CREDENTIALS_MODE_INVALID"
| "HEADER_NOT_ALLOWED"
| "FORBIDDEN_HEADER"
| "REQUEST_BODY_TOO_LARGE"
| "DEADLINE_EXPIRED"
| "SCOPE_FENCED";
/**
* §7.4. Runs after the credential patch and immediately before dispatch. A
* failure here means `fetch()` is called zero times, so a defective auth
* adapter can never alter the method, target or transport policy.
*/
export function checkFinalInvariants(
input: FinalInvariantInput,
): FinalInvariantFailure | null {
if (input.request.method !== input.expectedMethod) return "METHOD_CHANGED";
const base = new URL(input.baseUrl);
let target: URL;
try {
target = new URL(input.request.url);
} catch {
return "URL_NOT_ALLOWED";
}
if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname)) {
return "URL_NOT_ALLOWED";
}
if (input.init.redirect !== "error") return "REDIRECT_MODE_INVALID";
if (
input.init.credentials !== "omit" &&
input.init.credentials !== "same-origin" &&
input.init.credentials !== "include"
) {
return "CREDENTIALS_MODE_INVALID";
}
for (const name of Object.keys(input.headers)) {
const lower = name.toLowerCase();
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
if (
lower !== "accept" &&
lower !== "content-type" &&
lower !== "idempotency-key" &&
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
) {
return "HEADER_NOT_ALLOWED";
}
}
if (
input.request.bodyBytes &&
input.request.bodyBytes.byteLength > input.requestByteLimit
) {
return "REQUEST_BODY_TOO_LARGE";
}
if (input.deadlineRemainingMs <= 0) return "DEADLINE_EXPIRED";
if (!input.scopeIsCurrent) return "SCOPE_FENCED";
return null;
}
function failure(failureKind: RequestProjectionFailure): RequestProjectionOutcome {
return Object.freeze({ ok: false as const, failure: failureKind });
}
+119
View File
@@ -0,0 +1,119 @@
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,
});
}
/** §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
+134
View File
@@ -0,0 +1,134 @@
import type { ApiOperation } from "../../contracts/api-operations.ts";
export type OperationRequestInput = Readonly<{
operationId: string;
routeId: string;
pathParams?: Readonly<Record<string, string | number>>;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
idempotencyKey?: string;
correlationId?: string;
}>;
export type RequestTargetResult =
| Readonly<{ success: true; url: URL }>
| Readonly<{
success: false;
code:
| "BASE_URL_INVALID"
| "PATH_PARAMETER_MISSING"
| "PATH_PARAMETER_UNEXPECTED"
| "PATH_PARAMETER_INVALID"
| "SEARCH_PARAMETER_INVALID";
}>;
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
export function buildRequestTarget(
baseUrl: string,
operation: ApiOperation,
pathParams: Readonly<Record<string, string | number>> = {},
parsedSearch: unknown = {},
): RequestTargetResult {
let base: URL;
try {
base = new URL(baseUrl);
} catch {
return { success: false, code: "BASE_URL_INVALID" };
}
if (
(base.protocol !== "https:" &&
!(
base.protocol === "http:" &&
["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)
)) ||
base.username ||
base.password ||
base.search ||
base.hash
) {
return { success: false, code: "BASE_URL_INVALID" };
}
const placeholders = new Set<string>();
for (const match of operation.path.matchAll(pathParameterPattern)) {
placeholders.add(match[1] ?? match[2] ?? "");
}
if (Object.keys(pathParams).some((key) => !placeholders.has(key))) {
return { success: false, code: "PATH_PARAMETER_UNEXPECTED" };
}
let missingPathParameter = false;
let invalidPathParameter = false;
const pathname = operation.path.replace(
pathParameterPattern,
(_token, colonName: string | undefined, braceName: string | undefined) => {
const name = colonName ?? braceName ?? "";
const value = pathParams[name];
if (value === undefined) {
missingPathParameter = true;
return "";
}
const serialized = String(value);
if (
serialized.length === 0 ||
serialized.length > 512 ||
[...serialized].some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
})
) {
invalidPathParameter = true;
return "";
}
return encodeURIComponent(serialized);
},
);
if (missingPathParameter) {
return { success: false, code: "PATH_PARAMETER_MISSING" };
}
if (invalidPathParameter) {
return { success: false, code: "PATH_PARAMETER_INVALID" };
}
if (
parsedSearch === null ||
typeof parsedSearch !== "object" ||
Array.isArray(parsedSearch)
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
const basePrefix = base.pathname.endsWith("/")
? base.pathname
: `${base.pathname}/`;
const relativePath = pathname.replace(/^\/+/, "");
base.pathname = `${basePrefix}${relativePath}`.replace(/\/{2,}/g, "/");
const url = base;
const search = parsedSearch as Readonly<Record<string, unknown>>;
for (const key of Object.keys(search).sort((left, right) =>
left.localeCompare(right),
)) {
const value = search[key];
if (value === undefined || value === null) continue;
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
if (
typeof item !== "string" &&
typeof item !== "number" &&
typeof item !== "boolean"
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
url.searchParams.append(key, String(item));
}
}
if (
operation.maxEncodedSearchBytes !== undefined &&
new TextEncoder().encode(url.search).byteLength >
operation.maxEncodedSearchBytes
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
return { success: true, url };
}
+11
View File
@@ -0,0 +1,11 @@
import {
mappingFailure,
type MappingResult,
} from "../../contracts/boundary-mapper.ts";
export function mapOperationPayload(
_operationId: string,
_payload: unknown,
): MappingResult<never> {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
+86
View File
@@ -0,0 +1,86 @@
const retryKinds: ReadonlySet<string> = new Set([
"NETWORK_UNREACHABLE",
"REQUEST_TIMEOUT",
"RATE_LIMITED",
"SERVER_FAILURE",
]);
export function calculateBackoff(
retryIndex: number,
random = Math.random,
baseDelayMs = 250,
maxDelayMs = 2_000,
): number {
return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random();
}
export function parseRetryAfter(
value: string | null | undefined,
now = Date.now(),
): number | null {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) {
return seconds < 0 ? null : seconds * 1_000;
}
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return null;
return Math.max(0, timestamp - now);
}
export type RetryOperation = Readonly<{
idempotency: "safe" | "keyed" | "none";
retry?: "runtime" | "never";
}>;
export type RetryFailure = Readonly<{
kind: string;
retryAfterMs?: number;
retryAfter?: string;
httpStatus?: number;
}>;
export function shouldRetry(
operation: RetryOperation,
failure: RetryFailure,
retryCount: number,
maxRetries = 2,
): boolean {
if (operation.retry === "never") return false;
if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false;
if (
failure.kind === "SERVER_FAILURE" &&
failure.httpStatus !== undefined &&
![502, 503, 504].includes(failure.httpStatus)
) {
return false;
}
if (
failure.kind === "RATE_LIMITED" &&
typeof failure.retryAfterMs === "number" &&
failure.retryAfterMs > 30_000
) {
return false;
}
return operation.idempotency === "safe" || operation.idempotency === "keyed";
}
export function retryDelay(
failure: RetryFailure,
retryIndex: number,
random = Math.random,
now = Date.now(),
): number {
const localBackoff = calculateBackoff(retryIndex, random);
if (failure.kind !== "RATE_LIMITED") return localBackoff;
const retryAfterMs =
typeof failure.retryAfterMs === "number"
? failure.retryAfterMs
: parseRetryAfter(failure.retryAfter, now);
return retryAfterMs === null ? localBackoff : Math.max(localBackoff, retryAfterMs);
}
+112
View File
@@ -0,0 +1,112 @@
import { z } from "zod";
const metaSchema = z
.object({
requestId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/),
traceId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/),
correlationId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/).optional(),
})
.strip();
export const successEnvelopeSchema = z
.object({
success: z.literal(true),
data: z.unknown(),
meta: metaSchema,
})
.strict();
export const failureEnvelopeSchema = z
.object({
success: z.literal(false),
error: z
.object({
code: z.string().regex(/^[A-Z0-9_]{1,64}$/),
category: z.string().min(1).max(64).optional(),
message: z.string().max(1_024).optional(),
retryable: z.boolean().optional(),
details: z.unknown().optional(),
})
.strict(),
meta: metaSchema,
})
.strict();
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
successEnvelopeSchema,
failureEnvelopeSchema,
]);
const payloadSchemas: Readonly<Record<string, z.ZodType<unknown>>> =
Object.freeze({});
const requestSchemas: Readonly<Record<string, z.ZodType<unknown>>> =
Object.freeze({});
export type SchemaIssue = Readonly<{
path: string;
code: string;
schemaId?: string;
}>;
export type SchemaValidationResult =
| Readonly<{ success: true; data: unknown }>
| Readonly<{ success: false; issues: readonly SchemaIssue[] }>;
export function validateEnvelope(value: unknown): SchemaValidationResult {
return projectResult(responseEnvelopeSchema.safeParse(value));
}
export function validateOperationPayload(
schemaId: string,
value: unknown,
): SchemaValidationResult {
const schema = payloadSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
export function validateOperationRequest(
schemaId: string,
value: unknown,
): SchemaValidationResult {
const schema = requestSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
function missingSchema(schemaId: string): SchemaValidationResult {
return {
success: false,
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
};
}
function projectResult(
result:
| Readonly<{ success: true; data: unknown }>
| Readonly<{
success: false;
error: Readonly<{
issues: readonly Readonly<{
path: readonly PropertyKey[];
code: string;
}>[];
}>;
}>,
): SchemaValidationResult {
if (result.success) {
return {
success: true,
data: structuredClone(result.data),
};
}
return {
success: false,
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
+186
View File
@@ -0,0 +1,186 @@
/**
* §20.4. The single owner of window lifecycle listeners.
*
* No capability adds its own `visibilitychange`, `online`, `offline`, `focus`,
* `pagehide` or `pageshow` listener. They subscribe here instead, so the
* listener count stays constant and leak inspection (§23.14) is meaningful.
*
* §20.6: nothing in this module is a correctness boundary. `beforeunload` is a
* user prompt, never a place to complete a command, write a checkpoint or
* guarantee a lease release.
*/
export type BrowserLifecycleSnapshot = Readonly<{
visibility: "VISIBLE" | "HIDDEN";
connectivityHint: "ONLINE" | "OFFLINE";
pageState: "ACTIVE" | "PAGEHIDE" | "BFCACHE_RESTORED";
generation: number;
}>;
export type BrowserLifecycleEvent =
| Readonly<{ kind: "VISIBILITY_CHANGED"; snapshot: BrowserLifecycleSnapshot }>
| Readonly<{ kind: "ONLINE"; snapshot: BrowserLifecycleSnapshot }>
| Readonly<{ kind: "OFFLINE"; snapshot: BrowserLifecycleSnapshot }>
| Readonly<{ kind: "FOCUS"; snapshot: BrowserLifecycleSnapshot }>
| Readonly<{
kind: "PAGEHIDE";
persisted: boolean;
snapshot: BrowserLifecycleSnapshot;
}>
| Readonly<{
kind: "PAGESHOW";
persisted: boolean;
snapshot: BrowserLifecycleSnapshot;
}>;
export type BrowserLifecycleRuntime = Readonly<{
getSnapshot(): BrowserLifecycleSnapshot;
subscribe(listener: (event: BrowserLifecycleEvent) => void): () => void;
/**
* Registers a dirty-state source. `beforeunload` is attached only while at
* least one source reports dirty, and it uses the browser's standard prompt.
*/
registerDirtySource(isDirty: () => boolean): () => void;
dispose(): void;
}>;
type LifecycleHost = Readonly<{
addEventListener: Window["addEventListener"];
removeEventListener: Window["removeEventListener"];
document?: Pick<Document, "visibilityState"> & {
addEventListener: Document["addEventListener"];
removeEventListener: Document["removeEventListener"];
};
navigator?: Pick<Navigator, "onLine">;
}>;
export function createBrowserLifecycleRuntime(
host: LifecycleHost = globalThis as unknown as LifecycleHost,
): BrowserLifecycleRuntime {
const listeners = new Set<(event: BrowserLifecycleEvent) => void>();
const dirtySources = new Set<() => boolean>();
const document = host.document;
let generation = 1;
let visibility: BrowserLifecycleSnapshot["visibility"] =
document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE";
let connectivityHint: BrowserLifecycleSnapshot["connectivityHint"] =
host.navigator?.onLine === false ? "OFFLINE" : "ONLINE";
let pageState: BrowserLifecycleSnapshot["pageState"] = "ACTIVE";
let disposed = false;
let beforeUnloadAttached = false;
function snapshot(): BrowserLifecycleSnapshot {
return Object.freeze({
visibility,
connectivityHint,
pageState,
generation,
});
}
function publish(event: BrowserLifecycleEvent): void {
for (const listener of listeners) {
try {
listener(event);
} catch {
// One subscriber defect cannot suppress the signal for the others.
}
}
}
const onVisibility = () => {
visibility = document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE";
publish({ kind: "VISIBILITY_CHANGED", snapshot: snapshot() });
};
const onOnline = () => {
connectivityHint = "ONLINE";
publish({ kind: "ONLINE", snapshot: snapshot() });
};
const onOffline = () => {
connectivityHint = "OFFLINE";
publish({ kind: "OFFLINE", snapshot: snapshot() });
};
const onFocus = () => {
publish({ kind: "FOCUS", snapshot: snapshot() });
};
const onPageHide = (event: Event) => {
const persisted = (event as PageTransitionEvent).persisted === true;
pageState = "PAGEHIDE";
publish({ kind: "PAGEHIDE", persisted, snapshot: snapshot() });
};
const onPageShow = (event: Event) => {
const persisted = (event as PageTransitionEvent).persisted === true;
// §20.5. A bfcache restore is a new lifecycle generation, not a fresh boot.
if (persisted) generation += 1;
pageState = persisted ? "BFCACHE_RESTORED" : "ACTIVE";
publish({ kind: "PAGESHOW", persisted, snapshot: snapshot() });
};
const onBeforeUnload = (event: Event) => {
if (!hasDirtyState()) return;
event.preventDefault();
};
function hasDirtyState(): boolean {
for (const isDirty of dirtySources) {
try {
if (isDirty()) return true;
} catch {
// A defective reporter is treated as clean rather than trapping the user.
}
}
return false;
}
function syncBeforeUnload(): void {
const shouldAttach = dirtySources.size > 0;
if (shouldAttach && !beforeUnloadAttached) {
host.addEventListener("beforeunload", onBeforeUnload);
beforeUnloadAttached = true;
} else if (!shouldAttach && beforeUnloadAttached) {
host.removeEventListener("beforeunload", onBeforeUnload);
beforeUnloadAttached = false;
}
}
document?.addEventListener("visibilitychange", onVisibility);
host.addEventListener("online", onOnline);
host.addEventListener("offline", onOffline);
host.addEventListener("focus", onFocus);
host.addEventListener("pagehide", onPageHide);
host.addEventListener("pageshow", onPageShow);
return Object.freeze({
getSnapshot: snapshot,
subscribe(listener) {
if (disposed) return () => {};
listeners.add(listener);
return () => listeners.delete(listener);
},
registerDirtySource(isDirty) {
if (disposed) return () => {};
dirtySources.add(isDirty);
syncBeforeUnload();
return () => {
dirtySources.delete(isDirty);
syncBeforeUnload();
};
},
dispose() {
if (disposed) return;
disposed = true;
document?.removeEventListener("visibilitychange", onVisibility);
host.removeEventListener("online", onOnline);
host.removeEventListener("offline", onOffline);
host.removeEventListener("focus", onFocus);
host.removeEventListener("pagehide", onPageHide);
host.removeEventListener("pageshow", onPageShow);
if (beforeUnloadAttached) {
host.removeEventListener("beforeunload", onBeforeUnload);
beforeUnloadAttached = false;
}
listeners.clear();
dirtySources.clear();
},
});
}
@@ -0,0 +1,50 @@
import type { MutationIntentFactory } from "../../application/ports/mutation-intent-factory.ts";
import { defineMutationIntent } from "../../contracts/mutation-intent.ts";
export type BrowserMutationIntentFactoryDependencies = Readonly<{
randomUUID?: () => string;
monotonicNow?: () => number;
}>;
export function createBrowserMutationIntentFactory(
dependencies: BrowserMutationIntentFactoryDependencies = {},
): MutationIntentFactory {
const randomUUID =
dependencies.randomUUID ??
(() => {
if (
typeof globalThis.crypto === "undefined" ||
typeof globalThis.crypto.randomUUID !== "function"
) {
throw new TypeError("Secure mutation identity generation is unavailable.");
}
return globalThis.crypto.randomUUID();
});
const monotonicNow =
dependencies.monotonicNow ??
(() => {
if (
typeof globalThis.performance === "undefined" ||
typeof globalThis.performance.now !== "function"
) {
throw new TypeError("Monotonic time is unavailable.");
}
return globalThis.performance.now();
});
return Object.freeze({
create(input) {
const intentId = randomUUID();
const idempotencyKey = input.requiresIdempotencyKey
? randomUUID()
: undefined;
return defineMutationIntent({
intentId,
operationId: input.operationId,
canonicalInputIdentity: input.canonicalInputIdentity,
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
createdAtMonotonicMs: monotonicNow(),
});
},
});
}
+24
View File
@@ -0,0 +1,24 @@
import type { ClockPort } from "../../application/ports/clock-port.ts";
export const systemClock: ClockPort = Object.freeze({
now: () => Date.now(),
sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(signal?.reason);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
},
});
@@ -0,0 +1,123 @@
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
export type ConditionalValidatorBinding = Readonly<{
definitionId: string;
identityToken: string;
representationVersion: number;
scope: CacheScopeSnapshot;
}>;
export type ConditionalValidatorStore = Readonly<{
install(
binding: ConditionalValidatorBinding,
validator: string,
cacheRevision: number,
): boolean;
prepare(
binding: ConditionalValidatorBinding,
cacheRevision: number,
): string | null;
acceptNotModified(
binding: ConditionalValidatorBinding,
cacheRevision: number,
hasMappedValue: boolean,
): boolean;
remove(binding: ConditionalValidatorBinding): void;
clear(): void;
}>;
type ValidatorRow = {
validator: string;
cacheRevision: number;
generation: number;
};
export function createConditionalValidatorStore(
maxEntries = 1_024,
): ConditionalValidatorStore {
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
throw new TypeError("Invalid conditional validator capacity.");
}
const rows = new Map<string, ValidatorRow>();
function key(binding: ConditionalValidatorBinding): string | null {
if (
!binding.scope.isCurrent() ||
!binding.definitionId ||
!/^[A-Za-z0-9._:-]{16,128}$/.test(binding.identityToken) ||
!Number.isSafeInteger(binding.representationVersion) ||
binding.representationVersion < 1
) {
return null;
}
return [
binding.scope.fingerprint,
binding.definitionId,
binding.identityToken,
binding.representationVersion,
].join(":");
}
return Object.freeze({
install(binding, validator, cacheRevision) {
const selectedKey = key(binding);
if (
!selectedKey ||
!isSafeEntityTag(validator) ||
!Number.isSafeInteger(cacheRevision) ||
cacheRevision < 0
) {
return false;
}
if (!rows.has(selectedKey) && rows.size >= maxEntries) return false;
rows.set(selectedKey, {
validator,
cacheRevision,
generation: binding.scope.generation,
});
return true;
},
prepare(binding, cacheRevision) {
const selectedKey = key(binding);
if (!selectedKey) return null;
const row = rows.get(selectedKey);
return row &&
row.generation === binding.scope.generation &&
row.cacheRevision === cacheRevision
? row.validator
: null;
},
acceptNotModified(binding, cacheRevision, hasMappedValue) {
const selectedKey = key(binding);
if (!selectedKey || !hasMappedValue) return false;
const row = rows.get(selectedKey);
return Boolean(
row &&
row.generation === binding.scope.generation &&
row.cacheRevision === cacheRevision,
);
},
remove(binding) {
const selectedKey = key(binding);
if (selectedKey) rows.delete(selectedKey);
},
clear() {
rows.clear();
},
});
}
function isSafeEntityTag(value: string): boolean {
if (value.length < 3 || value.length > 256) return false;
const opaque = value.startsWith('W/"')
? value.slice(3, -1)
: value.startsWith('"')
? value.slice(1, -1)
: null;
if (opaque === null || !value.endsWith('"')) return false;
return [...opaque].every((character) => {
const code = character.codePointAt(0) ?? 0;
return code === 0x21 || (code >= 0x23 && code <= 0x7e) ||
(code >= 0x80 && code <= 0xff);
});
}
@@ -0,0 +1,139 @@
import type { Result } from "../../application/result.ts";
import type {
CursorPage,
CursorPaginationProfile,
CursorPaginationRuntime,
} from "../../contracts/cursor-pagination.ts";
import { createFailure } from "../../contracts/errors.ts";
export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
definitionId: string;
profile: CursorPaginationProfile;
loadPage(
cursor: string | null,
context: Readonly<{ signal?: AbortSignal }>,
): Promise<Result<CursorPage<Value>>>;
}>): CursorPaginationRuntime<Value> {
validateProfile(dependencies.profile);
return Object.freeze({
async loadAll(context) {
const items: Value[] = [];
const cursors = new Set<string>();
let cursor: string | null = null;
let snapshot: string | null | undefined;
for (
let pageIndex = 0;
pageIndex < dependencies.profile.maxPages;
pageIndex += 1
) {
if (context.signal?.aborted) {
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
}
const result = await dependencies.loadPage(cursor, context);
if (!result.ok) return result;
const page = result.value;
if (!isValidPage(page, dependencies.profile)) {
return failure(
"PAGINATION_CONTRACT_VIOLATION",
"PAGINATION_PAGE_INVALID",
);
}
if (snapshot === undefined) {
snapshot = page.snapshotToken;
} else if (snapshot !== page.snapshotToken) {
return failure(
"PAGINATION_CONTRACT_VIOLATION",
"PAGINATION_SNAPSHOT_CHANGED",
);
}
items.push(...page.items);
if (
items.length > dependencies.profile.maxTotalItems ||
estimatedBytes(items) > dependencies.profile.maxEstimatedBytes
) {
return failure(
"RESULT_LIMIT_EXCEEDED",
"PAGINATION_RESULT_LIMIT",
);
}
if (!page.hasMore) return { ok: true, value: Object.freeze(items) };
const nextCursor = page.nextCursor;
if (!nextCursor || cursors.has(nextCursor)) {
return failure(
"PAGINATION_CONTRACT_VIOLATION",
"PAGINATION_CURSOR_LOOP",
);
}
cursors.add(nextCursor);
cursor = nextCursor;
}
return failure(
"RESULT_LIMIT_EXCEEDED",
"PAGINATION_PAGE_LIMIT",
);
},
});
function failure(
kind:
| "PAGINATION_CONTRACT_VIOLATION"
| "RESULT_LIMIT_EXCEEDED"
| "REQUEST_ABORTED",
code: string,
) {
return {
ok: false as const,
error: createFailure(kind, dependencies.definitionId, 0, { code }),
};
}
}
function validateProfile(profile: CursorPaginationProfile): void {
if (
!profile.profileId ||
!Number.isSafeInteger(profile.maxPages) ||
profile.maxPages < 1 ||
profile.maxPages > 100 ||
!Number.isSafeInteger(profile.maxTotalItems) ||
profile.maxTotalItems < 1 ||
!Number.isSafeInteger(profile.maxEstimatedBytes) ||
profile.maxEstimatedBytes < 1 ||
!Number.isSafeInteger(profile.maxCursorBytes) ||
profile.maxCursorBytes < 1 ||
profile.maxCursorBytes > 4_096
) {
throw new TypeError("Invalid cursor pagination profile.");
}
}
function isValidPage<Value>(
page: CursorPage<Value>,
profile: CursorPaginationProfile,
): boolean {
const encoder = new TextEncoder();
return (
Boolean(page) &&
Array.isArray(page.items) &&
typeof page.hasMore === "boolean" &&
page.hasMore === (page.nextCursor !== null) &&
(page.nextCursor === null ||
(typeof page.nextCursor === "string" &&
page.nextCursor.length > 0 &&
encoder.encode(page.nextCursor).byteLength <=
profile.maxCursorBytes)) &&
(page.snapshotToken === null ||
(typeof page.snapshotToken === "string" &&
page.snapshotToken.length > 0 &&
encoder.encode(page.snapshotToken).byteLength <=
profile.maxCursorBytes)) &&
(profile.allowSparsePage || !page.hasMore || page.items.length > 0)
);
}
function estimatedBytes(value: unknown): number {
try {
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
} catch {
return Number.POSITIVE_INFINITY;
}
}
@@ -0,0 +1,202 @@
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
import type { QueryInvalidationCoordinator } from "../../contracts/query-invalidation.ts";
import {
createRuntimeIdentityRegistry,
type RuntimeIdentityRegistry,
} from "../../contracts/query-keys.ts";
import type {
CacheScopeSnapshot,
ClientScopeLifecycleEvent,
ClientScopePhase,
ServerStateScopeRuntime,
} from "../../contracts/server-state-scope.ts";
/**
* 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: Pick<QueryInvalidationCoordinator, "resetLocal">;
tokenFactory?: () => string;
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();
function createSnapshot(): CacheScopeSnapshot {
const capturedGeneration = generation;
const capturedIdentities = identities;
return Object.freeze({
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
.catch(() => {})
.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();
publishLifecycle(
Object.freeze({ kind: "READY" as const, snapshot: currentSnapshot }),
);
publishSnapshot();
});
});
return Object.freeze({
getSnapshot: () => currentSnapshot,
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();
},
});
}
function newIdentityRegistry(
tokenFactory: (() => string) | undefined,
): RuntimeIdentityRegistry {
return createRuntimeIdentityRegistry({
...(tokenFactory ? { tokenFactory } : {}),
});
}
function scopeFingerprint(tokenFactory: (() => string) | undefined): string {
const candidate = tokenFactory?.() ?? crypto.randomUUID();
if (!/^[A-Za-z0-9._:-]{16,128}$/.test(candidate)) {
throw new TypeError("Invalid cache scope fingerprint.");
}
return candidate;
}
@@ -0,0 +1,319 @@
import type { QueryClient } from "@tanstack/react-query";
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import type {
InvalidationRegistryIndex,
QueryInvalidationCoordinator,
QueryInvalidationTopic,
QueryMutationLease,
} from "../../contracts/query-invalidation.ts";
import { INVALIDATION_REGISTRY_BOUNDS } from "../../contracts/query-invalidation.ts";
import {
createQueryInvalidationPrefix,
queryNamespaceIdentityKey,
type QueryNamespaceIdentity,
} from "../../contracts/query-keys.ts";
import type {
BrowserCrossContextInvalidation,
CrossContextInvalidationDelivery,
} from "../cross-context-invalidation/index.ts";
export type TanStackCacheCoordinatorDependencies = Readonly<{
queryClient: QueryClient;
invalidationIndex: InvalidationRegistryIndex;
topicVersions: ReadonlyMap<string, number>;
crossContext?: BrowserCrossContextInvalidation;
diagnostics?: DiagnosticsPort;
}>;
/**
* Joins registry-owned invalidation topics to TanStack Query without putting a
* query key or cached value on the cross-context wire.
*/
export function createTanStackCacheCoordinator(
dependencies: TanStackCacheCoordinatorDependencies,
): QueryInvalidationCoordinator {
validateConfiguration(
dependencies.invalidationIndex,
dependencies.topicVersions,
);
const mutationLeases = new Map<QueryInvalidationTopic, number>();
const pendingRemote = new Set<QueryInvalidationTopic>();
let disposed = false;
let resetting = false;
let lifecycleGeneration = 0;
let flushPromise: Promise<void> | null = null;
let resetPromise: Promise<void> | null = null;
const unsubscribe = dependencies.crossContext?.subscribe((delivery) => {
receiveRemote(delivery);
});
function namespacesFor(
topic: QueryInvalidationTopic,
): readonly QueryNamespaceIdentity[] {
const selected = dependencies.invalidationIndex.namespacesForTopic.get(topic);
if (!selected) {
throw new TypeError("Unregistered query invalidation topic.");
}
return selected;
}
async function invalidateLocal(
topics: readonly QueryInvalidationTopic[],
expectedGeneration = lifecycleGeneration,
): Promise<void> {
if (
disposed ||
resetting ||
expectedGeneration !== lifecycleGeneration
) {
return;
}
const namespaces = new Map<string, QueryNamespaceIdentity>();
for (const topic of topics) {
for (const namespace of namespacesFor(topic)) {
namespaces.set(queryNamespaceIdentityKey(namespace), namespace);
}
}
for (const namespace of namespaces.values()) {
try {
await dependencies.queryClient.invalidateQueries({
queryKey: createQueryInvalidationPrefix(namespace),
exact: false,
refetchType: "active",
});
} catch {
report("invalidate");
}
}
}
function receiveRemote(
delivery: CrossContextInvalidationDelivery,
): void {
if (disposed) return;
if (
!dependencies.invalidationIndex.namespacesForTopic.has(
delivery.event.topic,
)
) {
report("unknown-topic");
return;
}
const selectedTopic = delivery.event.topic as QueryInvalidationTopic;
if (delivery.ordering === "GAP") {
for (const topic of dependencies.invalidationIndex.namespacesForTopic.keys()) {
pendingRemote.add(topic as QueryInvalidationTopic);
}
report("sequence-gap");
} else {
pendingRemote.add(selectedTopic);
}
if (!resetting) void flushRemote();
}
function flushRemote(): Promise<void> {
if (disposed || resetting) return Promise.resolve();
if (flushPromise) return flushPromise;
const expectedGeneration = lifecycleGeneration;
flushPromise = Promise.resolve()
.then(async () => {
while (
!disposed &&
!resetting &&
expectedGeneration === lifecycleGeneration
) {
const ready = [...pendingRemote].filter(
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
);
if (ready.length === 0) return;
for (const topic of ready) {
pendingRemote.delete(topic);
}
await invalidateLocal(ready, expectedGeneration);
}
})
.catch(() => {
report("remote-flush");
})
.finally(() => {
flushPromise = null;
if (
!disposed &&
!resetting &&
[...pendingRemote].some(
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
)
) {
void flushRemote();
}
});
return flushPromise;
}
function uniqueTopics(
topics: readonly QueryInvalidationTopic[],
): readonly QueryInvalidationTopic[] {
const unique = [...new Set(topics)];
for (const topic of unique) namespacesFor(topic);
return unique;
}
function report(operation: string): void {
try {
dependencies.diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation,
error_kind: "QUERY_CACHE_FAILURE",
},
});
} catch {
// Cache correctness and cleanup do not depend on diagnostics.
}
}
return Object.freeze({
async invalidate(
topics: readonly QueryInvalidationTopic[],
): Promise<void> {
if (disposed) return;
const selectedTopics = uniqueTopics(topics);
await invalidateLocal(selectedTopics);
for (const topic of selectedTopics) {
const topicVersion = dependencies.topicVersions.get(topic);
if (topicVersion === undefined) {
throw new TypeError("Unregistered query invalidation topic.");
}
const published = dependencies.crossContext?.publish({
topic,
topicVersion,
});
if (published && !published.ok) {
report("cross-context-publish");
}
}
},
beginMutation(
topics: readonly QueryInvalidationTopic[],
): QueryMutationLease {
if (disposed) {
throw new TypeError("Query invalidation coordinator is disposed.");
}
const selectedTopics = uniqueTopics(topics);
for (const topic of selectedTopics) {
mutationLeases.set(
topic,
(mutationLeases.get(topic) ?? 0) + 1,
);
}
let released = false;
return Object.freeze({
async release() {
if (released) return;
released = true;
for (const topic of selectedTopics) {
const remaining = (mutationLeases.get(topic) ?? 1) - 1;
if (remaining <= 0) {
mutationLeases.delete(topic);
} else {
mutationLeases.set(topic, remaining);
}
}
await flushRemote();
},
});
},
async resetLocal() {
if (disposed) return;
if (resetPromise) return resetPromise;
resetting = true;
lifecycleGeneration += 1;
pendingRemote.clear();
mutationLeases.clear();
const activeFlush = flushPromise;
resetPromise = (async () => {
try {
await activeFlush;
} 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;
if (
!disposed &&
[...pendingRemote].some(
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
)
) {
void flushRemote();
}
});
return resetPromise;
},
dispose() {
if (disposed) return;
disposed = true;
unsubscribe?.();
dependencies.crossContext?.close();
pendingRemote.clear();
mutationLeases.clear();
flushPromise = null;
resetPromise = null;
},
});
}
function validateConfiguration(
index: InvalidationRegistryIndex,
topicVersions: ReadonlyMap<string, number>,
): void {
if (
index.namespacesForTopic.size > INVALIDATION_REGISTRY_BOUNDS.maxTopics ||
topicVersions.size !== index.namespacesForTopic.size
) {
throw new TypeError("Query invalidation registry is invalid.");
}
for (const [topic, namespaces] of index.namespacesForTopic) {
const version = topicVersions.get(topic);
if (
namespaces.length < 1 ||
!Number.isSafeInteger(version) ||
(version ?? 0) < 1
) {
throw new TypeError("Query invalidation registry is invalid.");
}
const namespaceKeys = new Set<string>();
for (const namespace of namespaces) {
const key = queryNamespaceIdentityKey(namespace);
if (namespaceKeys.has(key)) {
throw new TypeError("Query invalidation registry is invalid.");
}
namespaceKeys.add(key);
}
}
for (const topic of topicVersions.keys()) {
if (!index.namespacesForTopic.has(topic)) {
throw new TypeError("Query invalidation registry is invalid.");
}
}
}
@@ -0,0 +1,115 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { createFailure } from "../../contracts/errors.ts";
import { safeErrorKind } from "../../contracts/diagnostics.ts";
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import type { QueryCachePort } from "../../application/ports/query-cache-port.ts";
export type QueryCacheDependencies = Readonly<{
diagnostics?: DiagnosticsPort;
}>;
export const QUERY_CACHE_DEFAULTS = Object.freeze({
staleTime: 30_000,
gcTime: 300_000,
refetchOnWindowFocus: true,
retry: false,
mutationRetry: false,
persistence: false,
});
export function createQueryClient(
dependencies: QueryCacheDependencies = {},
): QueryClient {
function report(operation: string, error: unknown): void {
try {
dependencies.diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation,
error_kind: safeErrorKind(error),
},
});
} catch {
// Query behavior remains independent from diagnostics.
}
}
return new QueryClient({
queryCache: new QueryCache({
onError: (error) => report("query", error),
}),
mutationCache: new MutationCache({
onError: (error) => report("mutation", error),
}),
defaultOptions: {
queries: {
staleTime: QUERY_CACHE_DEFAULTS.staleTime,
gcTime: QUERY_CACHE_DEFAULTS.gcTime,
refetchOnWindowFocus: QUERY_CACHE_DEFAULTS.refetchOnWindowFocus,
retry: QUERY_CACHE_DEFAULTS.retry,
},
mutations: {
retry: QUERY_CACHE_DEFAULTS.mutationRetry,
},
},
});
}
export function createQueryCacheAdapter(
queryClient: QueryClient,
dependencies: QueryCacheDependencies = {},
): QueryCachePort {
return Object.freeze({
read(key) {
try {
return { ok: true, value: queryClient.getQueryData(key) };
} catch {
return cacheFailure("read", key, dependencies.diagnostics);
}
},
write(key, value) {
try {
queryClient.setQueryData(key, structuredClone(value));
return { ok: true };
} catch {
return cacheFailure("write", key, dependencies.diagnostics);
}
},
async invalidate(namespace) {
try {
await queryClient.invalidateQueries({ queryKey: namespace, exact: false });
return { ok: true };
} catch {
return cacheFailure("invalidate", namespace, dependencies.diagnostics);
}
},
});
}
function cacheFailure(
phase: string,
key: readonly unknown[],
diagnostics: DiagnosticsPort | undefined,
): Readonly<{ ok: false; error: ReturnType<typeof createFailure> }> {
const namespace = typeof key[0] === "string" ? key[0] : "unknown";
try {
diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation: phase,
error_kind: "QUERY_CACHE_FAILURE",
},
});
} catch {
// Cache behavior remains independent from diagnostics.
}
return {
ok: false,
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
code: `QUERY_CACHE_${phase.toUpperCase()}_FAILED`,
causeClass: `namespace:${namespace}`,
}),
};
}
+394
View File
@@ -0,0 +1,394 @@
import {
realtimeFailure,
realtimeSuccess,
type RealtimeResult,
} from "./result.ts";
import {
isCanonicalRealtimeSequence,
isRealtimeOpaqueIdentifier,
isRealtimeResumeCursor,
isRealtimeScopeBinding,
isStrictRealtimeTimestamp,
type RealtimeEventEnvelope,
} from "../../contracts/realtime-events.ts";
import {
REALTIME_EVENT_PROTOCOL,
REALTIME_HARD_LIMITS,
type RealtimeEventTypeRegistration,
type RealtimePolicyRegistry,
type RealtimeStreamRegistration,
} from "../../contracts/realtime-streams.ts";
import {
validateWithRuntimeSchemaRegistry,
type RuntimeSchemaCodec,
} from "../../contracts/schema-registry.ts";
import {
hasDuplicateJsonMembers,
} from "./json-member-scanner.ts";
export type ValidatedRealtimeEventDto = Readonly<{
envelope: RealtimeEventEnvelope;
wireBytes: number;
/**
* Adapter-private semantic identity used only by the bounded conflict
* detector. It must never be logged or projected into diagnostics.
*/
semanticFingerprint: string;
fingerprintBytes: number;
}>;
export type RealtimeEventCodec = Readonly<{
decode(raw: string): RealtimeResult<ValidatedRealtimeEventDto>;
}>;
export type RealtimeEventCodecDependencies = Readonly<{
registry: RealtimePolicyRegistry;
schemaCodecs: Readonly<Record<string, RuntimeSchemaCodec>>;
}>;
const ENVELOPE_KEYS = Object.freeze([
"eventId",
"eventType",
"occurredAt",
"payload",
"protocol",
"recoveryMode",
"resumeCursor",
"scopeBinding",
"sequence",
"streamEpoch",
"streamId",
] as const);
const FORBIDDEN_OBJECT_KEYS = new Set([
"__proto__",
"constructor",
"prototype",
]);
const encoder = new TextEncoder();
const issuedDtos = new WeakSet<object>();
export function createRealtimeEventCodec(
dependencies: RealtimeEventCodecDependencies,
): RealtimeEventCodec {
return Object.freeze({
decode(raw: string): RealtimeResult<ValidatedRealtimeEventDto> {
try {
return decodeUnsafe(raw, dependencies);
} catch {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
},
});
}
export function isValidatedRealtimeEventDto(
value: unknown,
): value is ValidatedRealtimeEventDto {
return (
!!value &&
typeof value === "object" &&
issuedDtos.has(value) &&
Object.isFrozen(value)
);
}
function decodeUnsafe(
raw: string,
dependencies: RealtimeEventCodecDependencies,
): RealtimeResult<ValidatedRealtimeEventDto> {
if (typeof raw !== "string") {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
if (
raw.length > REALTIME_HARD_LIMITS.maxEventBytes ||
encoder.encode(raw).byteLength > REALTIME_HARD_LIMITS.maxEventBytes
) {
return realtimeFailure("EVENT_TOO_LARGE", "DECODE");
}
const wireBytes = encoder.encode(raw).byteLength;
let input: unknown;
if (
hasDuplicateJsonMembers(raw, {
maxDepth: REALTIME_HARD_LIMITS.maxPayloadDepth + 2,
maxMembers: REALTIME_HARD_LIMITS.maxPayloadNodes + 32,
})
) {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
try {
input = JSON.parse(raw);
} catch {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
if (!hasExactEnvelopeKeys(input)) {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
if (input.protocol !== REALTIME_EVENT_PROTOCOL) {
return realtimeFailure("PROTOCOL_MISMATCH", "DECODE");
}
if (typeof input.streamId !== "string") {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
const stream = dependencies.registry.findStream(input.streamId);
if (!stream) {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
if (wireBytes > stream.limits.maxEventBytes) {
return realtimeFailure("EVENT_TOO_LARGE", "DECODE");
}
if (typeof input.eventType !== "string") {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
const eventType = dependencies.registry.findStreamEventType(
stream.id,
input.eventType,
);
if (!eventType) {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
if (!hasValidEnvelopeFields(input, stream)) {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
if (
!withinJsonBudget(
input.payload,
stream.limits.maxPayloadDepth,
stream.limits.maxPayloadNodes,
)
) {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
const payload = validateWithRuntimeSchemaRegistry(
eventType.payloadSchemaId,
input.payload,
dependencies.schemaCodecs,
);
if (!payload.success) {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
let payloadSnapshot: unknown;
try {
payloadSnapshot = snapshotJson(
payload.data,
stream.limits.maxPayloadDepth,
stream.limits.maxPayloadNodes,
);
} catch {
return realtimeFailure("MALFORMED_EVENT", "DECODE");
}
const envelope = createEnvelope(
input,
stream,
eventType,
payloadSnapshot,
);
const semanticFingerprint = canonicalJson(envelope);
const fingerprintBytes = encoder.encode(semanticFingerprint).byteLength;
if (fingerprintBytes > stream.limits.maxEventBytes) {
return realtimeFailure("EVENT_TOO_LARGE", "DECODE");
}
const dto = Object.freeze({
envelope,
wireBytes,
semanticFingerprint,
fingerprintBytes,
});
issuedDtos.add(dto);
return realtimeSuccess(dto);
}
function hasValidEnvelopeFields(
input: Readonly<Record<string, unknown>>,
stream: RealtimeStreamRegistration,
): boolean {
if (
!isRealtimeOpaqueIdentifier(input.streamEpoch) ||
!isRealtimeOpaqueIdentifier(input.eventId) ||
!isCanonicalRealtimeSequence(input.sequence) ||
!isStrictRealtimeTimestamp(input.occurredAt) ||
!isRealtimeScopeBinding(input.scopeBinding) ||
input.recoveryMode !== stream.recovery.mode
) {
return false;
}
return stream.recovery.mode === "CURSOR"
? isRealtimeResumeCursor(input.resumeCursor)
: input.resumeCursor === null;
}
function createEnvelope(
input: Readonly<Record<string, unknown>>,
stream: RealtimeStreamRegistration,
eventType: RealtimeEventTypeRegistration,
payload: unknown,
): RealtimeEventEnvelope {
const base = {
protocol: REALTIME_EVENT_PROTOCOL,
streamId: stream.id,
streamEpoch: input.streamEpoch as string,
eventType: eventType.id,
eventId: input.eventId as string,
sequence: input.sequence as string,
occurredAt: input.occurredAt as string,
scopeBinding: input.scopeBinding as string,
payload,
};
return stream.recovery.mode === "CURSOR"
? Object.freeze({
...base,
recoveryMode: "CURSOR" as const,
resumeCursor: input.resumeCursor as string,
})
: Object.freeze({
...base,
recoveryMode: stream.recovery.mode,
resumeCursor: null,
});
}
function hasExactEnvelopeKeys(
value: unknown,
): value is Readonly<Record<string, unknown>> {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.getPrototypeOf(value) !== Object.prototype
) {
return false;
}
const keys = Object.keys(value).sort();
return (
keys.length === ENVELOPE_KEYS.length &&
keys.every((key, index) => key === ENVELOPE_KEYS[index])
);
}
function withinJsonBudget(
value: unknown,
maxDepth: number,
maxNodes: number,
): boolean {
let nodes = 0;
const visit = (candidate: unknown, depth: number): boolean => {
nodes += 1;
if (nodes > maxNodes || depth > maxDepth) return false;
if (
candidate === null ||
typeof candidate === "string" ||
typeof candidate === "boolean" ||
(typeof candidate === "number" && Number.isFinite(candidate))
) {
return true;
}
if (Array.isArray(candidate)) {
return candidate.every((item) => visit(item, depth + 1));
}
if (
!candidate ||
typeof candidate !== "object" ||
Object.getPrototypeOf(candidate) !== Object.prototype
) {
return false;
}
return Object.entries(candidate).every(
([key, item]) =>
!FORBIDDEN_OBJECT_KEYS.has(key) && visit(item, depth + 1),
);
};
return visit(value, 0);
}
function snapshotJson(
value: unknown,
maxDepth: number,
maxNodes: number,
): unknown {
const seen = new WeakSet<object>();
let nodes = 0;
const visit = (candidate: unknown, depth: number): unknown => {
nodes += 1;
if (nodes > maxNodes || depth > maxDepth) {
throw new TypeError("Realtime payload exceeds its structural budget.");
}
if (
candidate === null ||
typeof candidate === "string" ||
typeof candidate === "boolean" ||
(typeof candidate === "number" && Number.isFinite(candidate))
) {
return candidate;
}
if (!candidate || typeof candidate !== "object") {
throw new TypeError("Realtime payload is not JSON-compatible.");
}
if (seen.has(candidate)) {
throw new TypeError("Realtime payload contains shared object identity.");
}
seen.add(candidate);
if (Array.isArray(candidate)) {
for (let index = 0; index < candidate.length; index += 1) {
if (!Object.hasOwn(candidate, index)) {
throw new TypeError("Realtime payload contains a sparse array.");
}
}
return Object.freeze(
candidate.map((item) => visit(item, depth + 1)),
);
}
if (
Object.getPrototypeOf(candidate) !== Object.prototype &&
Object.getPrototypeOf(candidate) !== null
) {
throw new TypeError("Realtime payload requires plain objects.");
}
const output: Record<string, unknown> = Object.create(null);
const descriptors = Object.getOwnPropertyDescriptors(candidate);
for (const key of Object.keys(descriptors).sort()) {
if (FORBIDDEN_OBJECT_KEYS.has(key)) {
throw new TypeError("Realtime payload contains a forbidden key.");
}
const descriptor = descriptors[key];
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("Realtime payload contains an accessor.");
}
output[key] = visit(descriptor.value, depth + 1);
}
return Object.freeze(output);
};
return visit(value, 0);
}
function canonicalJson(value: unknown): string {
if (
value === null ||
typeof value === "string" ||
typeof value === "boolean" ||
typeof value === "number"
) {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (!value || typeof value !== "object") {
throw new TypeError("Realtime semantic identity is invalid.");
}
return `{${Object.keys(value)
.sort()
.map(
(key) =>
`${JSON.stringify(key)}:${canonicalJson(
(value as Readonly<Record<string, unknown>>)[key],
)}`,
)
.join(",")}}`;
}
+226
View File
@@ -0,0 +1,226 @@
import type {
RealtimeAcceptDisposition,
RealtimeTransportEventOutcome,
} from "../../application/ports/realtime/event-authority.ts";
import {
REALTIME_TRANSPORT_CONTINUE,
realtimeTransportRecoveryCommitted,
} from "../../application/ports/realtime/event-authority.ts";
import type {
RealtimeResult,
} from "../../application/ports/realtime/shared.ts";
import {
isRealtimeResumeCursor,
} from "../../contracts/realtime-events.ts";
import type {
StreamRegistrationId,
} from "../../contracts/realtime-streams.ts";
import type {
RealtimeEventCodec,
} from "./event-codec.ts";
import type {
RealtimeStreamCoordinator,
} from "./stream-coordinator.ts";
import {
realtimeFailure,
realtimeSuccess,
} from "./result.ts";
export type RealtimeTransportCursor =
| Readonly<{
kind: "SSE_DIRECT_CURSOR";
eventId: string;
}>
| Readonly<{ kind: "SSE_NO_CURSOR" }>
| Readonly<{ kind: "ENCAPSULATED" }>;
export type RealtimeEventConsumer = Readonly<{
consume(
rawEnvelope: string,
cursor: RealtimeTransportCursor,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeAcceptDisposition>>;
consumeEncapsulated(
envelope: Readonly<Record<string, unknown>>,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeAcceptDisposition>>;
consumeForTransport(
rawEnvelope: string,
cursor: RealtimeTransportCursor,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeTransportEventOutcome>>;
consumeEncapsulatedForTransport(
envelope: Readonly<Record<string, unknown>>,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeTransportEventOutcome>>;
}>;
/**
* The single handoff from transport bytes to the common event authority.
* SSE's transport-level `id` is checked here against the validated envelope;
* WebSocket can carry the same envelope without inventing a second cursor.
*/
export function createRealtimeEventConsumer(
dependencies: Readonly<{
codec: RealtimeEventCodec;
coordinator: Pick<RealtimeStreamCoordinator, "accept">;
}>,
): RealtimeEventConsumer {
async function consumeWithStream(
rawEnvelope: string,
cursor: RealtimeTransportCursor,
signal?: AbortSignal,
): Promise<
Readonly<{
streamId: StreamRegistrationId | null;
result: RealtimeResult<RealtimeAcceptDisposition>;
}>
> {
if (signal?.aborted) {
return {
streamId: null,
result: realtimeFailure("ABORTED", "RECEIVE"),
};
}
const decoded = dependencies.codec.decode(rawEnvelope);
if (!decoded.ok) {
return { streamId: null, result: decoded };
}
const envelope = decoded.value.envelope;
if (
(cursor.kind === "SSE_DIRECT_CURSOR" &&
(!isRealtimeResumeCursor(cursor.eventId) ||
envelope.recoveryMode !== "CURSOR" ||
envelope.resumeCursor !== cursor.eventId)) ||
(cursor.kind === "SSE_NO_CURSOR" &&
(envelope.recoveryMode === "CURSOR" ||
envelope.resumeCursor !== null))
) {
return {
streamId: envelope.streamId,
result: realtimeFailure(
"PROTOCOL_MISMATCH",
"RECEIVE",
),
};
}
return {
streamId: envelope.streamId,
result: await dependencies.coordinator.accept(
decoded.value,
signal,
),
};
}
async function consume(
rawEnvelope: string,
cursor: RealtimeTransportCursor,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeAcceptDisposition>> {
return (
await consumeWithStream(rawEnvelope, cursor, signal)
).result;
}
async function consumeEncapsulated(
envelope: Readonly<Record<string, unknown>>,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeAcceptDisposition>> {
const serialized = serializeEnvelope(envelope);
if (!serialized.ok) return serialized;
return await consume(
serialized.value,
{ kind: "ENCAPSULATED" },
signal,
);
}
async function consumeForTransport(
rawEnvelope: string,
cursor: RealtimeTransportCursor,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeTransportEventOutcome>> {
const consumed = await consumeWithStream(
rawEnvelope,
cursor,
signal,
);
return projectTransportOutcome(
consumed.result,
consumed.streamId,
);
}
async function consumeEncapsulatedForTransport(
envelope: Readonly<Record<string, unknown>>,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeTransportEventOutcome>> {
const serialized = serializeEnvelope(envelope);
if (!serialized.ok) return serialized;
return await consumeForTransport(
serialized.value,
{ kind: "ENCAPSULATED" },
signal,
);
}
return Object.freeze({
consume,
consumeEncapsulated,
consumeForTransport,
consumeEncapsulatedForTransport,
});
}
function projectTransportOutcome(
accepted: RealtimeResult<RealtimeAcceptDisposition>,
streamId: StreamRegistrationId | null,
): RealtimeResult<RealtimeTransportEventOutcome> {
if (!accepted.ok) return accepted;
if (
accepted.value.outcome === "RECOVERED" ||
accepted.value.outcome === "RECOVERY_BARRIER_REQUIRED"
) {
if (streamId === null) {
return realtimeFailure("PROTOCOL_MISMATCH", "RECEIVE");
}
return realtimeSuccess(
realtimeTransportRecoveryCommitted(
streamId,
accepted.value.resumeState,
),
);
}
if (accepted.value.outcome === "APPLIED") {
return realtimeSuccess(REALTIME_TRANSPORT_CONTINUE);
}
switch (accepted.value.reason) {
case "DUPLICATE_EVENT":
case "STALE_EVENT":
return realtimeSuccess(REALTIME_TRANSPORT_CONTINUE);
case "RECOVERY_IN_PROGRESS":
return realtimeFailure(
"PROTOCOL_MISMATCH",
"RECEIVE",
);
case "CLOSED":
return realtimeFailure("CLOSED", "RECEIVE");
case "SCOPE_FENCED":
return realtimeFailure("SCOPE_FENCED", "RECEIVE");
}
}
function serializeEnvelope(
envelope: Readonly<Record<string, unknown>>,
): RealtimeResult<string> {
let rawEnvelope: string;
try {
rawEnvelope = JSON.stringify(envelope);
} catch {
return realtimeFailure("MALFORMED_EVENT", "RECEIVE");
}
return typeof rawEnvelope === "string"
? realtimeSuccess(rawEnvelope)
: realtimeFailure("MALFORMED_EVENT", "RECEIVE");
}
+58
View File
@@ -0,0 +1,58 @@
export {
createRealtimeEventCodec,
isValidatedRealtimeEventDto,
type RealtimeEventCodec,
type RealtimeEventCodecDependencies,
type ValidatedRealtimeEventDto,
} from "./event-codec.ts";
export {
createRealtimeEventConsumer,
type RealtimeEventConsumer,
type RealtimeTransportCursor,
} from "./event-consumer.ts";
export {
calculateReconnectDelay,
defineReconnectPolicy,
isReconnectAttemptResetEligible,
parseRetryAfterDelay,
REALTIME_RECONNECT_CEILINGS,
reconnectBudgetRemaining,
type ReconnectDelayInput,
type ReconnectPolicy,
} from "./reconnect-policy.ts";
export {
createRealtimeReconnectCoordinator,
type RealtimeCommittedRecovery,
type RealtimeReconnectAttemptContext,
type RealtimeReconnectAttemptSuccess,
type RealtimeReconnectCloseClassification,
type RealtimeReconnectCoordinator,
type RealtimeReconnectCoordinatorDependencies,
type RealtimeReconnectEnvironment,
type RealtimeReconnectOutcome,
type RealtimeReconnectRunInput,
type RealtimeReconnectSession,
type RealtimeRecoveryReconnectDirective,
} from "./reconnect-coordinator.ts";
export {
createLivePollHandoffCoordinator,
LIVE_POLL_HANDOFF_CEILINGS,
type LivePollHandoffCoordinator,
type LivePollHandoffCoordinatorDependencies,
type LivePollHandoffInspection,
type LivePollHandoffLimits,
type LivePollHandoffRecoveryInput,
type LivePollHandoffState,
type LivePollWriterKind,
type LivePollWriterLease,
type LivePollWriteReceipt,
type LiveProbeLease,
} from "./live-poll-handoff-coordinator.ts";
export {
createRealtimeStreamCoordinator,
type RealtimeStreamCoordinator,
type RealtimeStreamCoordinatorDependencies,
} from "./stream-coordinator.ts";
export * from "./polling/index.ts";
export * from "./sse/index.ts";
export * from "./websocket/index.ts";
@@ -0,0 +1,218 @@
type Container =
| {
kind: "OBJECT";
state: "KEY_OR_END" | "COLON" | "VALUE" | "COMMA_OR_END";
keys: Set<string>;
}
| {
kind: "ARRAY";
state: "VALUE_OR_END" | "COMMA_OR_END";
};
/**
* Scans already byte-bounded JSON before `JSON.parse` can apply last-wins
* semantics. Invalid input and scanner budget exhaustion are both rejected.
*/
export function hasDuplicateJsonMembers(
source: string,
limits: Readonly<{
maxDepth: number;
maxMembers: number;
}>,
): boolean {
try {
return scan(source, limits);
} catch {
return true;
}
}
function scan(
source: string,
limits: Readonly<{
maxDepth: number;
maxMembers: number;
}>,
): boolean {
if (
typeof source !== "string" ||
!Number.isSafeInteger(limits.maxDepth) ||
limits.maxDepth < 1 ||
!Number.isSafeInteger(limits.maxMembers) ||
limits.maxMembers < 1
) {
return true;
}
const stack: Container[] = [];
let cursor = skipWhitespace(source, 0);
let rootStarted = false;
let rootComplete = false;
let members = 0;
const consumeValue = (): boolean => {
cursor = skipWhitespace(source, cursor);
const character = source[cursor];
if (character === "{") {
if (stack.length + 1 > limits.maxDepth) return false;
stack.push({
kind: "OBJECT",
state: "KEY_OR_END",
keys: new Set(),
});
cursor += 1;
return true;
}
if (character === "[") {
if (stack.length + 1 > limits.maxDepth) return false;
stack.push({ kind: "ARRAY", state: "VALUE_OR_END" });
cursor += 1;
return true;
}
if (character === "\"") {
const end = jsonStringEnd(source, cursor);
if (end === null) return false;
cursor = end;
return true;
}
const end = primitiveEnd(source, cursor);
if (end === cursor) return false;
cursor = end;
return true;
};
while (!rootComplete) {
if (!rootStarted) {
rootStarted = true;
if (!consumeValue()) return true;
if (stack.length === 0) rootComplete = true;
continue;
}
const container = stack.at(-1);
if (!container) {
rootComplete = true;
continue;
}
cursor = skipWhitespace(source, cursor);
if (container.kind === "ARRAY") {
if (container.state === "VALUE_OR_END") {
if (source[cursor] === "]") {
cursor += 1;
stack.pop();
if (stack.length === 0) rootComplete = true;
continue;
}
container.state = "COMMA_OR_END";
if (!consumeValue()) return true;
continue;
}
if (source[cursor] === ",") {
cursor += 1;
container.state = "VALUE_OR_END";
continue;
}
if (source[cursor] === "]") {
cursor += 1;
stack.pop();
if (stack.length === 0) rootComplete = true;
continue;
}
return true;
}
if (container.state === "KEY_OR_END") {
if (source[cursor] === "}") {
cursor += 1;
stack.pop();
if (stack.length === 0) rootComplete = true;
continue;
}
if (source[cursor] !== "\"") return true;
const end = jsonStringEnd(source, cursor);
if (end === null) return true;
const key = JSON.parse(source.slice(cursor, end)) as unknown;
if (typeof key !== "string" || container.keys.has(key)) {
return true;
}
members += 1;
if (members > limits.maxMembers) return true;
container.keys.add(key);
cursor = end;
container.state = "COLON";
continue;
}
if (container.state === "COLON") {
if (source[cursor] !== ":") return true;
cursor += 1;
container.state = "VALUE";
continue;
}
if (container.state === "VALUE") {
container.state = "COMMA_OR_END";
if (!consumeValue()) return true;
continue;
}
if (source[cursor] === ",") {
cursor += 1;
container.state = "KEY_OR_END";
continue;
}
if (source[cursor] === "}") {
cursor += 1;
stack.pop();
if (stack.length === 0) rootComplete = true;
continue;
}
return true;
}
return skipWhitespace(source, cursor) !== source.length;
}
function jsonStringEnd(source: string, start: number): number | null {
let escaped = false;
for (let cursor = start + 1; cursor < source.length; cursor += 1) {
const character = source[cursor];
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === "\"") {
return cursor + 1;
}
}
return null;
}
function primitiveEnd(source: string, start: number): number {
let cursor = start;
while (
cursor < source.length &&
source[cursor] !== "," &&
source[cursor] !== "]" &&
source[cursor] !== "}" &&
!isWhitespace(source[cursor])
) {
cursor += 1;
}
return cursor;
}
function skipWhitespace(source: string, start: number): number {
let cursor = start;
while (cursor < source.length && isWhitespace(source[cursor])) {
cursor += 1;
}
return cursor;
}
function isWhitespace(character: string | undefined): boolean {
return (
character === " " ||
character === "\n" ||
character === "\r" ||
character === "\t"
);
}
@@ -0,0 +1,883 @@
import type { ClockPort } from "../../application/ports/clock-port.ts";
import {
type RealtimeFailure,
type RealtimeOperation,
type RealtimeResult,
} from "../../application/ports/realtime/shared.ts";
import { systemClock } from "../platform/system-clock.ts";
import {
isRealtimeResult,
realtimeFailure,
realtimeSuccess,
} from "./result.ts";
export const LIVE_POLL_HANDOFF_CEILINGS = Object.freeze({
maxQuiescenceTimeoutMs: 30_000,
maxActiveQueueCount: 256,
maxActiveQueueBytes: 4 * 1024 * 1024,
maxProbeBufferedEvents: 256,
maxProbeBufferedBytes: 4 * 1024 * 1024,
maxItemBytes: 64 * 1024,
} as const);
export type LivePollHandoffState =
| "LIVE_ACTIVE"
| "POLL_ACTIVE"
| "LIVE_PROBING"
| "CLOSED";
export type LivePollWriterKind = "LIVE" | "POLL";
export type LivePollHandoffLimits = Readonly<{
quiescenceTimeoutMs: number;
maxActiveQueueCount: number;
maxActiveQueueBytes: number;
maxProbeBufferedEvents: number;
maxProbeBufferedBytes: number;
maxItemBytes: number;
}>;
export type LivePollWriteReceipt = Readonly<{
kind: "APPLIED" | "BUFFERED";
writer: LivePollWriterKind;
generation: number;
}>;
export type LivePollWriterLease<Value> = Readonly<{
writer: LivePollWriterKind;
generation: number;
signal: AbortSignal;
isCurrent(): boolean;
write(
value: Value,
wireBytes: number,
): Promise<RealtimeResult<LivePollWriteReceipt>>;
}>;
export type LiveProbeLease<Value> = LivePollWriterLease<Value> &
Readonly<{
writer: "LIVE";
activate(): Promise<RealtimeResult<LivePollWriterLease<Value>>>;
cancel(): RealtimeResult<LivePollWriterLease<Value>>;
}>;
export type LivePollHandoffInspection = Readonly<{
state: LivePollHandoffState;
activeWriter: LivePollWriterKind | null;
activeGeneration: number | null;
probeGeneration: number | null;
bufferedEvents: number;
bufferedBytes: number;
transitioning: boolean;
}>;
export type LivePollHandoffRecoveryInput = Readonly<{
from: LivePollWriterKind;
to: LivePollWriterKind;
candidateGeneration: number;
signal: AbortSignal;
/**
* Must be checked immediately before committing the checkpoint projection.
*/
isCurrent(): boolean;
}>;
export type LivePollHandoffCoordinator<Value> = Readonly<{
currentWriter(): LivePollWriterLease<Value> | null;
switchToPoll(): Promise<RealtimeResult<LivePollWriterLease<Value>>>;
beginLiveProbe(): RealtimeResult<LiveProbeLease<Value>>;
inspect(): LivePollHandoffInspection;
close(): Promise<RealtimeResult<void>>;
}>;
export type LivePollHandoffCoordinatorDependencies<Value> = Readonly<{
initial: Readonly<{
writer: LivePollWriterKind;
authoritativeCheckpointEstablished: true;
}>;
limits: LivePollHandoffLimits;
apply(input: Readonly<{
writer: LivePollWriterKind;
generation: number;
value: Value;
signal: AbortSignal;
/**
* Must be checked immediately before committing the external effect.
*/
isCurrent(): boolean;
}>): Promise<RealtimeResult<void>>;
establishAuthoritativeCheckpoint(
input: LivePollHandoffRecoveryInput,
): Promise<RealtimeResult<void>>;
clock?: ClockPort;
}>;
type BufferedValue<Value> = Readonly<{
value: Value;
wireBytes: number;
}>;
type InternalWriterLease<Value> = {
readonly writer: LivePollWriterKind;
readonly generation: number;
readonly controller: AbortController;
facade: LivePollWriterLease<Value>;
tail: Promise<void>;
queuedCount: number;
queuedBytes: number;
};
type InternalProbe<Value> = {
readonly lease: InternalWriterLease<Value>;
facade: LiveProbeLease<Value>;
readonly buffer: BufferedValue<Value>[];
bufferedBytes: number;
acceptedEvents: number;
acceptedBytes: number;
};
type QuiescenceOutcome = "QUIESCED" | "TIMER_FAILED" | "TIMED_OUT";
export function createLivePollHandoffCoordinator<Value>(
dependencies: LivePollHandoffCoordinatorDependencies<Value>,
): LivePollHandoffCoordinator<Value> {
if (
!dependencies ||
!dependencies.initial ||
(dependencies.initial.writer !== "LIVE" &&
dependencies.initial.writer !== "POLL") ||
dependencies.initial.authoritativeCheckpointEstablished !== true ||
typeof dependencies.apply !== "function" ||
typeof dependencies.establishAuthoritativeCheckpoint !== "function"
) {
throw new TypeError(
"Invalid live/poll handoff dependencies or initial checkpoint.",
);
}
const limits = validateLimits(dependencies.limits);
const clock = dependencies.clock ?? systemClock;
let state: LivePollHandoffState =
dependencies.initial.writer === "LIVE"
? "LIVE_ACTIVE"
: "POLL_ACTIVE";
let generationCounter = 0;
let lifecycleGeneration = 0;
let transitioning = false;
let active: InternalWriterLease<Value> | null = null;
let probe: InternalProbe<Value> | null = null;
let quiescing: InternalWriterLease<Value> | null = null;
let transitionCandidate: InternalWriterLease<Value> | null = null;
let closePromise: Promise<RealtimeResult<void>> | null = null;
active = createWriterLease(dependencies.initial.writer);
function createWriterLease(
writer: LivePollWriterKind,
): InternalWriterLease<Value> {
const controller = new AbortController();
const generation = ++generationCounter;
const lease: InternalWriterLease<Value> = {
writer,
generation,
controller,
tail: Promise.resolve(),
queuedCount: 0,
queuedBytes: 0,
facade: null as unknown as LivePollWriterLease<Value>,
};
lease.facade = Object.freeze({
writer,
generation,
signal: controller.signal,
isCurrent: () => isActiveLease(lease),
write: async (value: Value, wireBytes: number) =>
await writeFromLease(lease, value, wireBytes),
});
return lease;
}
function createProbe(): InternalProbe<Value> {
const lease = createWriterLease("LIVE");
const selected: InternalProbe<Value> = {
lease,
buffer: [],
bufferedBytes: 0,
acceptedEvents: 0,
acceptedBytes: 0,
facade: null as unknown as LiveProbeLease<Value>,
};
selected.facade = Object.freeze({
...lease.facade,
writer: "LIVE" as const,
activate: async () => await activateProbe(selected),
cancel: () => cancelProbe(selected),
});
return selected;
}
async function writeFromLease(
lease: InternalWriterLease<Value>,
value: Value,
wireBytes: number,
): Promise<RealtimeResult<LivePollWriteReceipt>> {
if (state === "CLOSED") return handoffFailure("CLOSED", "APPLY");
if (probe?.lease === lease && state === "LIVE_PROBING") {
if (!validWireBytes(wireBytes, limits.maxItemBytes)) {
return handoffFailure("EVENT_TOO_LARGE", "APPLY");
}
return bufferProbeValue(probe, value, wireBytes);
}
if (!isActiveLease(lease)) {
return handoffFailure("SCOPE_FENCED", "APPLY");
}
if (!validWireBytes(wireBytes, limits.maxItemBytes)) {
return handoffFailure("EVENT_TOO_LARGE", "APPLY");
}
return await enqueueEffect(lease, value, wireBytes);
}
function bufferProbeValue(
selected: InternalProbe<Value>,
value: Value,
wireBytes: number,
): RealtimeResult<LivePollWriteReceipt> {
if (
selected.acceptedEvents + 1 >
limits.maxProbeBufferedEvents ||
selected.acceptedBytes + wireBytes >
limits.maxProbeBufferedBytes
) {
if (transitioning) {
failClosed();
} else {
selected.lease.controller.abort();
selected.buffer.length = 0;
selected.bufferedBytes = 0;
probe = null;
state = "POLL_ACTIVE";
}
return handoffFailure("QUEUE_OVERFLOW", "APPLY");
}
selected.buffer.push(Object.freeze({ value, wireBytes }));
selected.bufferedBytes += wireBytes;
selected.acceptedEvents += 1;
selected.acceptedBytes += wireBytes;
return realtimeSuccess(
Object.freeze({
kind: "BUFFERED" as const,
writer: "LIVE" as const,
generation: selected.lease.generation,
}),
);
}
function enqueueEffect(
lease: InternalWriterLease<Value>,
value: Value,
wireBytes: number,
): Promise<RealtimeResult<LivePollWriteReceipt>> {
if (
lease.queuedCount + 1 > limits.maxActiveQueueCount ||
lease.queuedBytes + wireBytes > limits.maxActiveQueueBytes
) {
failClosed();
return Promise.resolve(
handoffFailure("QUEUE_OVERFLOW", "APPLY"),
);
}
lease.queuedCount += 1;
lease.queuedBytes += wireBytes;
const result = lease.tail.then(async () => {
try {
if (!isEffectAuthorized(lease)) {
return handoffFailure("SCOPE_FENCED", "APPLY");
}
return await invokeApply(lease, value);
} finally {
lease.queuedCount -= 1;
lease.queuedBytes -= wireBytes;
}
});
lease.tail = result.then(
() => undefined,
() => undefined,
);
return result;
}
async function invokeApply(
lease: InternalWriterLease<Value>,
value: Value,
): Promise<RealtimeResult<LivePollWriteReceipt>> {
try {
const result = await dependencies.apply(
Object.freeze({
writer: lease.writer,
generation: lease.generation,
value,
signal: lease.controller.signal,
isCurrent: () => isEffectAuthorized(lease),
}),
);
if (!isRealtimeResult(result, isUndefined)) {
return handoffFailure(
"MAPPING_CONTRACT_VIOLATION",
"APPLY",
);
}
if (!result.ok) {
return Object.freeze({
ok: false,
error: result.error,
});
}
if (!isEffectAuthorized(lease)) {
return handoffFailure("SCOPE_FENCED", "APPLY");
}
return realtimeSuccess(
Object.freeze({
kind: "APPLIED" as const,
writer: lease.writer,
generation: lease.generation,
}),
);
} catch {
return handoffFailure("PROVIDER_UNAVAILABLE", "APPLY", true);
}
}
async function switchToPoll(): Promise<
RealtimeResult<LivePollWriterLease<Value>>
> {
if (state === "CLOSED") {
return handoffFailure("CLOSED", "RECOVER");
}
if (
state !== "LIVE_ACTIVE" ||
transitioning ||
active?.writer !== "LIVE"
) {
return handoffFailure("PROTOCOL_MISMATCH", "RECOVER");
}
transitioning = true;
const transitionGeneration = ++lifecycleGeneration;
const previous = active;
const candidate = createWriterLease("POLL");
transitionCandidate = candidate;
active = null;
quiescing = previous;
previous.controller.abort();
const quiescence = await awaitQuiescence(previous);
if (!transitionIsCurrent(transitionGeneration, candidate)) {
candidate.controller.abort();
return handoffFailure("CLOSED", "RECOVER");
}
if (quiescence !== "QUIESCED") {
failClosed();
return handoffFailure(
quiescence === "TIMED_OUT"
? "IDLE_TIMEOUT"
: "PROVIDER_UNAVAILABLE",
"RECOVER",
);
}
quiescing = null;
const checkpoint = await establishCheckpoint(
previous.writer,
candidate,
);
if (
!checkpoint.ok ||
!transitionIsCurrent(transitionGeneration, candidate)
) {
failClosed();
return checkpoint.ok
? handoffFailure("CLOSED", "RECOVER")
: checkpoint;
}
active = candidate;
transitionCandidate = null;
state = "POLL_ACTIVE";
transitioning = false;
return realtimeSuccess(candidate.facade);
}
function beginLiveProbe(): RealtimeResult<LiveProbeLease<Value>> {
if (state === "CLOSED") {
return handoffFailure("CLOSED", "SUBSCRIBE");
}
if (
state !== "POLL_ACTIVE" ||
transitioning ||
probe !== null ||
active?.writer !== "POLL"
) {
return handoffFailure("PROTOCOL_MISMATCH", "SUBSCRIBE");
}
const candidate = createProbe();
probe = candidate;
state = "LIVE_PROBING";
return realtimeSuccess(candidate.facade);
}
function cancelProbe(
selected: InternalProbe<Value>,
): RealtimeResult<LivePollWriterLease<Value>> {
if (state === "CLOSED") {
return handoffFailure("CLOSED", "CLOSE");
}
if (
state !== "LIVE_PROBING" ||
transitioning ||
probe !== selected ||
active?.writer !== "POLL"
) {
return handoffFailure("SCOPE_FENCED", "CLOSE");
}
selected.lease.controller.abort();
selected.buffer.length = 0;
selected.bufferedBytes = 0;
probe = null;
state = "POLL_ACTIVE";
return realtimeSuccess(active.facade);
}
async function activateProbe(
selected: InternalProbe<Value>,
): Promise<RealtimeResult<LivePollWriterLease<Value>>> {
if (state === "CLOSED") {
return handoffFailure("CLOSED", "RECOVER");
}
if (
state !== "LIVE_PROBING" ||
transitioning ||
probe !== selected ||
active?.writer !== "POLL"
) {
return handoffFailure("SCOPE_FENCED", "RECOVER");
}
transitioning = true;
const transitionGeneration = ++lifecycleGeneration;
const previous = active;
transitionCandidate = selected.lease;
active = null;
quiescing = previous;
previous.controller.abort();
const quiescence = await awaitQuiescence(previous);
if (!probeTransitionIsCurrent(transitionGeneration, selected)) {
selected.lease.controller.abort();
return handoffFailure("CLOSED", "RECOVER");
}
if (quiescence !== "QUIESCED") {
failClosed();
return handoffFailure(
quiescence === "TIMED_OUT"
? "IDLE_TIMEOUT"
: "PROVIDER_UNAVAILABLE",
"RECOVER",
);
}
quiescing = null;
const checkpoint = await establishCheckpoint(
previous.writer,
selected.lease,
);
if (
!checkpoint.ok ||
!probeTransitionIsCurrent(transitionGeneration, selected)
) {
failClosed();
return checkpoint.ok
? handoffFailure("CLOSED", "RECOVER")
: checkpoint;
}
while (selected.buffer.length > 0) {
if (!probeTransitionIsCurrent(transitionGeneration, selected)) {
return handoffFailure("CLOSED", "RECOVER");
}
const buffered = selected.buffer.shift();
if (!buffered) break;
selected.bufferedBytes -= buffered.wireBytes;
const applied = await enqueueEffect(
selected.lease,
buffered.value,
buffered.wireBytes,
);
if (!applied.ok) {
failClosed();
return Object.freeze({
ok: false,
error: remapFailure(applied.error, "RECOVER"),
});
}
}
if (!probeTransitionIsCurrent(transitionGeneration, selected)) {
return handoffFailure("CLOSED", "RECOVER");
}
active = selected.lease;
transitionCandidate = null;
probe = null;
state = "LIVE_ACTIVE";
transitioning = false;
return realtimeSuccess(selected.lease.facade);
}
async function establishCheckpoint(
from: LivePollWriterKind,
candidate: InternalWriterLease<Value>,
): Promise<RealtimeResult<void>> {
const timer = new AbortController();
let releaseAbortGate = (): void => undefined;
const aborted = new Promise<
Readonly<{ kind: "ABORTED" }>
>((resolve) => {
const onAbort = () => resolve({ kind: "ABORTED" });
candidate.controller.signal.addEventListener(
"abort",
onAbort,
{ once: true },
);
releaseAbortGate = () =>
candidate.controller.signal.removeEventListener(
"abort",
onAbort,
);
if (candidate.controller.signal.aborted) onAbort();
});
const operation = Promise.resolve()
.then(() =>
dependencies.establishAuthoritativeCheckpoint(
Object.freeze({
from,
to: candidate.writer,
candidateGeneration: candidate.generation,
signal: candidate.controller.signal,
isCurrent: () =>
isCheckpointCandidateCurrent(candidate),
}),
),
)
.then(
(value) => ({ kind: "VALUE" as const, value }),
() => ({ kind: "REJECTED" as const }),
);
const timeout = Promise.resolve()
.then(async () => {
await clock.sleep(
limits.quiescenceTimeoutMs,
timer.signal,
);
return { kind: "TIMED_OUT" as const };
})
.catch(() => ({
kind: timer.signal.aborted
? ("CANCELED" as const)
: ("TIMER_FAILED" as const),
}));
const selected = await Promise.race([
operation,
timeout,
aborted,
]);
timer.abort();
releaseAbortGate();
if (selected.kind === "ABORTED") {
return handoffFailure("ABORTED", "RECOVER");
}
if (selected.kind === "TIMED_OUT") {
candidate.controller.abort();
return handoffFailure("IDLE_TIMEOUT", "RECOVER");
}
if (
selected.kind === "TIMER_FAILED" ||
selected.kind === "REJECTED"
) {
candidate.controller.abort();
return handoffFailure(
"PROVIDER_UNAVAILABLE",
"RECOVER",
true,
);
}
if (selected.kind === "CANCELED") {
return handoffFailure("ABORTED", "RECOVER");
}
if (selected.kind !== "VALUE") {
candidate.controller.abort();
return handoffFailure(
"PROVIDER_UNAVAILABLE",
"RECOVER",
true,
);
}
const result = selected.value;
if (!isRealtimeResult(result, isUndefined)) {
candidate.controller.abort();
return handoffFailure(
"MAPPING_CONTRACT_VIOLATION",
"RECOVER",
);
}
if (!result.ok) {
return Object.freeze({
ok: false,
error: remapFailure(result.error, "RECOVER"),
});
}
if (candidate.controller.signal.aborted) {
return handoffFailure("ABORTED", "RECOVER");
}
return realtimeSuccess(undefined);
}
async function awaitQuiescence(
lease: InternalWriterLease<Value>,
): Promise<QuiescenceOutcome> {
const timer = new AbortController();
const settled = lease.tail.then(
() => "QUIESCED" as const,
() => "QUIESCED" as const,
);
const timeout = Promise.resolve()
.then(async () => {
await clock.sleep(
limits.quiescenceTimeoutMs,
timer.signal,
);
return "TIMED_OUT" as const;
})
.catch(() =>
timer.signal.aborted
? ("QUIESCED" as const)
: ("TIMER_FAILED" as const),
);
const outcome = await Promise.race([settled, timeout]);
timer.abort();
return outcome;
}
function close(): Promise<RealtimeResult<void>> {
closePromise ??= performClose();
return closePromise;
}
async function performClose(): Promise<RealtimeResult<void>> {
lifecycleGeneration += 1;
state = "CLOSED";
transitioning = true;
const writers = uniqueLeases([
active,
probe?.lease ?? null,
quiescing,
transitionCandidate,
]);
active = null;
const selectedProbe = probe;
probe = null;
selectedProbe?.buffer.splice(0);
if (selectedProbe) selectedProbe.bufferedBytes = 0;
for (const writer of writers) writer.controller.abort();
const outcomes = await Promise.all(
writers.map(async (writer) => await awaitQuiescence(writer)),
);
quiescing = null;
transitionCandidate = null;
transitioning = false;
if (outcomes.includes("TIMED_OUT")) {
return handoffFailure("IDLE_TIMEOUT", "CLOSE");
}
if (outcomes.includes("TIMER_FAILED")) {
return handoffFailure("PROVIDER_UNAVAILABLE", "CLOSE");
}
return realtimeSuccess(undefined);
}
function inspect(): LivePollHandoffInspection {
return Object.freeze({
state,
activeWriter: active?.writer ?? null,
activeGeneration: active?.generation ?? null,
probeGeneration: probe?.lease.generation ?? null,
bufferedEvents: probe?.buffer.length ?? 0,
bufferedBytes: probe?.bufferedBytes ?? 0,
transitioning,
});
}
function isActiveLease(lease: InternalWriterLease<Value>): boolean {
if (active !== lease || transitioning || state === "CLOSED") {
return false;
}
return (
(lease.writer === "LIVE" && state === "LIVE_ACTIVE") ||
(lease.writer === "POLL" &&
(state === "POLL_ACTIVE" || state === "LIVE_PROBING"))
);
}
function isEffectAuthorized(
lease: InternalWriterLease<Value>,
): boolean {
return (
isActiveLease(lease) ||
(transitioning &&
state === "LIVE_PROBING" &&
probe?.lease === lease &&
!lease.controller.signal.aborted)
);
}
function transitionIsCurrent(
transitionGeneration: number,
candidate: InternalWriterLease<Value>,
): boolean {
return (
state !== "CLOSED" &&
transitioning &&
lifecycleGeneration === transitionGeneration &&
!candidate.controller.signal.aborted
);
}
function probeTransitionIsCurrent(
transitionGeneration: number,
selected: InternalProbe<Value>,
): boolean {
return (
state === "LIVE_PROBING" &&
transitioning &&
lifecycleGeneration === transitionGeneration &&
probe === selected &&
!selected.lease.controller.signal.aborted
);
}
function isCheckpointCandidateCurrent(
candidate: InternalWriterLease<Value>,
): boolean {
return (
state !== "CLOSED" &&
transitioning &&
transitionCandidate === candidate &&
!candidate.controller.signal.aborted
);
}
function failClosed(): void {
lifecycleGeneration += 1;
state = "CLOSED";
transitioning = false;
active?.controller.abort();
probe?.lease.controller.abort();
quiescing?.controller.abort();
transitionCandidate?.controller.abort();
active = null;
if (probe) {
probe.buffer.length = 0;
probe.bufferedBytes = 0;
}
probe = null;
}
return Object.freeze({
currentWriter: () => active?.facade ?? null,
switchToPoll,
beginLiveProbe,
inspect,
close,
});
}
function validateLimits(
limits: LivePollHandoffLimits,
): LivePollHandoffLimits {
if (
!positiveIntegerWithin(
limits.quiescenceTimeoutMs,
LIVE_POLL_HANDOFF_CEILINGS.maxQuiescenceTimeoutMs,
) ||
!positiveIntegerWithin(
limits.maxActiveQueueCount,
LIVE_POLL_HANDOFF_CEILINGS.maxActiveQueueCount,
) ||
!positiveIntegerWithin(
limits.maxActiveQueueBytes,
LIVE_POLL_HANDOFF_CEILINGS.maxActiveQueueBytes,
) ||
!positiveIntegerWithin(
limits.maxProbeBufferedEvents,
LIVE_POLL_HANDOFF_CEILINGS.maxProbeBufferedEvents,
) ||
!positiveIntegerWithin(
limits.maxProbeBufferedBytes,
LIVE_POLL_HANDOFF_CEILINGS.maxProbeBufferedBytes,
) ||
!positiveIntegerWithin(
limits.maxItemBytes,
LIVE_POLL_HANDOFF_CEILINGS.maxItemBytes,
) ||
limits.maxItemBytes > limits.maxProbeBufferedBytes ||
limits.maxItemBytes > limits.maxActiveQueueBytes
) {
throw new TypeError("Invalid live/poll handoff limits.");
}
return Object.freeze({ ...limits });
}
function positiveIntegerWithin(value: number, maximum: number): boolean {
return (
Number.isSafeInteger(value) &&
value > 0 &&
value <= maximum
);
}
function validWireBytes(value: number, maximum: number): boolean {
return positiveIntegerWithin(value, maximum);
}
function isUndefined(value: unknown): value is undefined {
return value === undefined;
}
function handoffFailure(
kind: Parameters<typeof realtimeFailure>[0],
operation: RealtimeOperation,
retryable?: boolean,
): Extract<RealtimeResult<never>, { ok: false }> {
return retryable === undefined
? realtimeFailure(kind, operation)
: realtimeFailure(kind, operation, retryable);
}
function remapFailure(
failure: RealtimeFailure,
operation: RealtimeOperation,
): RealtimeFailure {
return Object.freeze({
kind: failure.kind,
operation,
retryable: failure.retryable,
});
}
function uniqueLeases<Value>(
values: readonly (InternalWriterLease<Value> | null)[],
): InternalWriterLease<Value>[] {
return [
...new Set(
values.filter(
(value): value is InternalWriterLease<Value> =>
value !== null,
),
),
];
}
@@ -0,0 +1,961 @@
import {
assertBoundedPollOperation,
definePollLeasePolicy,
type BoundedPollOperationContract,
type PollLeasePolicy,
} from "../../../application/policies/bounded-polling.ts";
import type { ClockPort } from "../../../application/ports/clock-port.ts";
import {
REALTIME_FAILURE_KINDS,
type RealtimeFailure,
type RealtimeFailureKind,
type RealtimeResult,
} from "../../../application/ports/realtime/shared.ts";
import { systemClock } from "../../platform/system-clock.ts";
import {
realtimeFailure,
realtimeSuccess,
} from "../result.ts";
/**
* Provider-private Retry-After metadata is consumed by this adapter and is
* deliberately stripped before a failure crosses the realtime boundary.
*/
export type BoundedPollAttemptFailure = RealtimeFailure & Readonly<{
retryAfterMs?: number;
}>;
type BoundedPollAttemptSuccess<Value> =
| Readonly<{
kind: "UNCHANGED";
responseBytes: 0;
}>
| Readonly<{
kind: "VALUE";
value: Value;
responseBytes: number;
state?: string;
}>;
export type BoundedPollAttemptResult<Value> =
| Extract<
RealtimeResult<BoundedPollAttemptSuccess<Value>>,
{ ok: true }
>
| Readonly<{ ok: false; error: BoundedPollAttemptFailure }>;
export type BoundedPollResult<Value> =
RealtimeResult<
Readonly<{
kind: "TERMINAL";
attempts: number;
state: string;
value: Value;
}>
>;
export type BoundedPollEnvironment = Readonly<{
visibility(): "HIDDEN" | "VISIBLE";
online(): boolean;
subscribeVisibility?(
listener: (visibility: "HIDDEN" | "VISIBLE") => void,
): () => void;
subscribeOnline?(listener: (online: boolean) => void): () => void;
}>;
export type BoundedPollRunInput<Value> = Readonly<{
signal?: AbortSignal;
onValue?: (
value: Value,
context: Readonly<{ signal: AbortSignal; isCurrent(): boolean }>,
) => void | Promise<void>;
}>;
export type BoundedPollCoordinator<Value> = Readonly<{
run(input?: BoundedPollRunInput<Value>): Promise<
BoundedPollResult<Value>
>;
getState(): "CLOSED" | "DRAINING" | "IDLE" | "RUNNING";
close(): void;
}>;
export type BoundedPollCoordinatorDependencies<Value> = Readonly<{
policy: PollLeasePolicy;
operation: BoundedPollOperationContract;
execute(input: Readonly<{
operationId: string;
attempt: number;
/**
* Hard response-body ceiling that must be enforced before decoding.
*/
maxResponseBytes: number;
signal: AbortSignal;
}>): Promise<BoundedPollAttemptResult<Value>>;
environment: BoundedPollEnvironment;
isCurrent?: () => boolean;
clock?: ClockPort;
random?: () => number;
}>;
const SAFE_STATE = /^[A-Z][A-Z0-9_]{0,63}$/u;
const POLL_RETRYABLE_FAILURE_KINDS = Object.freeze([
"CONNECT_TIMEOUT",
"RATE_LIMITED",
"PROVIDER_UNAVAILABLE",
] as const satisfies readonly RealtimeFailureKind[]);
const POLL_RETRY_AFTER_FAILURE_KINDS = Object.freeze([
"RATE_LIMITED",
"PROVIDER_UNAVAILABLE",
] as const satisfies readonly RealtimeFailureKind[]);
export function createBoundedPollCoordinator<Value>(
dependencies: BoundedPollCoordinatorDependencies<Value>,
): BoundedPollCoordinator<Value> {
const policy = definePollLeasePolicy(dependencies.policy);
assertBoundedPollOperation(policy, dependencies.operation);
const maxResponseBytes = Math.min(
policy.maxResponseBytes,
dependencies.operation.maxResponseBytes,
);
const clock = dependencies.clock ?? systemClock;
const random = dependencies.random ?? Math.random;
const isCurrent = dependencies.isCurrent ?? (() => true);
let state: "CLOSED" | "DRAINING" | "IDLE" | "RUNNING" = "IDLE";
let activeController: AbortController | null = null;
let generation = 0;
let pendingWork = 0;
async function run(
input: BoundedPollRunInput<Value> = {},
): Promise<BoundedPollResult<Value>> {
if (state === "CLOSED") return pollFailure("CLOSED");
if (state !== "IDLE") {
return pollFailure("PROTOCOL_MISMATCH");
}
if (input.signal?.aborted) return pollFailure("ABORTED");
if (safeVisibility(dependencies.environment) !== "VISIBLE") {
return pollFailure("ABORTED");
}
if (safeOnline(dependencies.environment) !== true) {
return pollFailure("OFFLINE", true);
}
if (!safeIsCurrent(isCurrent)) {
return pollFailure("SCOPE_FENCED");
}
state = "RUNNING";
const runGeneration = ++generation;
const controller = new AbortController();
activeController = controller;
let stopKind: RealtimeFailureKind | null = null;
let attempts = 0;
let consecutiveFailures = 0;
let nextDelayMs = policy.minimumIntervalMs;
const startedAtMs = safeNow(clock);
const stop = (kind: RealtimeFailureKind) => {
if (stopKind !== null) return;
stopKind = kind;
controller.abort();
};
const onCallerAbort = () => stop("ABORTED");
input.signal?.addEventListener("abort", onCallerAbort, {
once: true,
});
let unsubscribeVisibility: (() => void) | undefined;
let unsubscribeOnline: (() => void) | undefined;
try {
unsubscribeVisibility =
dependencies.environment.subscribeVisibility?.((visibility) => {
if (visibility !== "VISIBLE") stop("ABORTED");
});
} catch {
stop("ABORTED");
}
try {
unsubscribeOnline =
dependencies.environment.subscribeOnline?.((online) => {
if (!online) stop("OFFLINE");
});
} catch {
stop("OFFLINE");
}
try {
if (startedAtMs === null) {
return pollFailure("PROVIDER_UNAVAILABLE");
}
while (true) {
const lifecycleFailure = currentFailure(
stopKind,
state,
runGeneration,
generation,
isCurrent,
dependencies.environment,
);
if (lifecycleFailure) {
return pollFailure(
lifecycleFailure,
lifecycleFailure === "OFFLINE",
);
}
if (attempts >= policy.maxAttempts) {
return pollFailure("POLL_BUDGET_EXHAUSTED");
}
const nowBeforeSleep = safeNow(clock);
if (
nowBeforeSleep === null ||
nowBeforeSleep < startedAtMs
) {
return pollFailure("PROVIDER_UNAVAILABLE");
}
if (
nowBeforeSleep - startedAtMs + nextDelayMs >=
policy.maxElapsedMs
) {
return pollFailure("POLL_BUDGET_EXHAUSTED");
}
const cadenceOutcome = await awaitTaskOrAbort(
() => clock.sleep(nextDelayMs, controller.signal),
controller.signal,
);
if (cadenceOutcome.kind !== "VALUE") {
const afterSleepFailure = currentFailure(
stopKind,
state,
runGeneration,
generation,
isCurrent,
dependencies.environment,
);
return pollFailure(
afterSleepFailure ??
(cadenceOutcome.kind === "THREW"
? "PROVIDER_UNAVAILABLE"
: "ABORTED"),
afterSleepFailure === "OFFLINE",
);
}
const beforeAttemptFailure = currentFailure(
stopKind,
state,
runGeneration,
generation,
isCurrent,
dependencies.environment,
);
if (beforeAttemptFailure) {
return pollFailure(
beforeAttemptFailure,
beforeAttemptFailure === "OFFLINE",
);
}
const attemptStartedAt = safeNow(clock);
if (
attemptStartedAt === null ||
attemptStartedAt < startedAtMs ||
attemptStartedAt - startedAtMs >= policy.maxElapsedMs
) {
return pollFailure("POLL_BUDGET_EXHAUSTED");
}
attempts += 1;
let result: BoundedPollAttemptResult<Value>;
const attemptOutcome = await awaitWithinLease(
() =>
trackWork(
dependencies.execute({
operationId: policy.operationId,
attempt: attempts,
maxResponseBytes,
signal: controller.signal,
}),
runGeneration,
),
policy.maxElapsedMs - (attemptStartedAt - startedAtMs),
clock,
controller.signal,
() => stop("POLL_BUDGET_EXHAUSTED"),
);
if (attemptOutcome.kind === "VALUE") {
result = attemptOutcome.value;
} else if (attemptOutcome.kind === "THREW") {
result = realtimeFailure(
controller.signal.aborted ? "ABORTED" : "OFFLINE",
"POLL",
!controller.signal.aborted,
);
} else {
if (attemptOutcome.kind === "CLOCK_FAILED") {
controller.abort();
}
const interruptedFailure = currentFailure(
stopKind,
state,
runGeneration,
generation,
isCurrent,
dependencies.environment,
);
return pollFailure(
interruptedFailure ??
(attemptOutcome.kind === "CLOCK_FAILED"
? "PROVIDER_UNAVAILABLE"
: "ABORTED"),
interruptedFailure === "OFFLINE",
);
}
const afterAttemptFailure = currentFailure(
stopKind,
state,
runGeneration,
generation,
isCurrent,
dependencies.environment,
);
if (afterAttemptFailure) {
return pollFailure(
afterAttemptFailure,
afterAttemptFailure === "OFFLINE",
);
}
const attemptFinishedAt = safeNow(clock);
if (
attemptFinishedAt === null ||
attemptFinishedAt < attemptStartedAt
) {
return pollFailure("PROVIDER_UNAVAILABLE");
}
if (
attemptFinishedAt - startedAtMs >= policy.maxElapsedMs
) {
return pollFailure("POLL_BUDGET_EXHAUSTED");
}
const parsedResult = parseAttemptResult<Value>(result);
if (!parsedResult) {
return pollFailure("PROTOCOL_MISMATCH");
}
result = parsedResult;
if (!result.ok) {
if (
!result.error.retryable ||
!isPollRetryableFailureKind(result.error.kind) ||
(isPollRetryAfterFailureKind(result.error.kind) &&
result.error.retryAfterMs === undefined)
) {
return pollFailure(result.error.kind);
}
consecutiveFailures += 1;
const failureDelay = retryDelay(
policy,
consecutiveFailures,
isPollRetryAfterFailureKind(result.error.kind)
? result.error.retryAfterMs
: undefined,
random,
);
if (failureDelay === null) {
return pollFailure("POLL_BUDGET_EXHAUSTED");
}
nextDelayMs = failureDelay;
continue;
}
consecutiveFailures = 0;
if (
result.value.responseBytes > maxResponseBytes
) {
return pollFailure("PROTOCOL_MISMATCH");
}
if (result.value.kind === "UNCHANGED") {
const delay = successDelay(policy, random);
if (delay === null) {
return pollFailure("PROTOCOL_MISMATCH");
}
nextDelayMs = delay;
continue;
}
const valueResult = result.value;
if (input.onValue) {
const beforeApplyAt = safeNow(clock);
if (
beforeApplyAt === null ||
beforeApplyAt < attemptFinishedAt ||
beforeApplyAt - startedAtMs >= policy.maxElapsedMs
) {
return pollFailure("POLL_BUDGET_EXHAUSTED");
}
const applyOutcome = await awaitWithinLease(
() =>
trackWork(
Promise.resolve(
input.onValue!(valueResult.value, {
signal: controller.signal,
isCurrent: () =>
state === "RUNNING" &&
generation === runGeneration &&
stopKind === null &&
!controller.signal.aborted &&
safeIsCurrent(isCurrent),
}),
),
runGeneration,
),
policy.maxElapsedMs - (beforeApplyAt - startedAtMs),
clock,
controller.signal,
() => stop("POLL_BUDGET_EXHAUSTED"),
);
if (applyOutcome.kind === "THREW") {
return applyFailure();
}
if (applyOutcome.kind !== "VALUE") {
if (applyOutcome.kind === "CLOCK_FAILED") {
controller.abort();
}
const interruptedFailure = currentFailure(
stopKind,
state,
runGeneration,
generation,
isCurrent,
dependencies.environment,
);
return pollFailure(
interruptedFailure ??
(applyOutcome.kind === "CLOCK_FAILED"
? "PROVIDER_UNAVAILABLE"
: "ABORTED"),
interruptedFailure === "OFFLINE",
);
}
}
const afterApplyFailure = currentFailure(
stopKind,
state,
runGeneration,
generation,
isCurrent,
dependencies.environment,
);
if (afterApplyFailure) {
return pollFailure(
afterApplyFailure,
afterApplyFailure === "OFFLINE",
);
}
const applyFinishedAt = safeNow(clock);
if (
applyFinishedAt === null ||
applyFinishedAt < attemptFinishedAt
) {
return pollFailure("PROVIDER_UNAVAILABLE");
}
if (applyFinishedAt - startedAtMs >= policy.maxElapsedMs) {
return pollFailure("POLL_BUDGET_EXHAUSTED");
}
if (
valueResult.state &&
policy.terminalStates.includes(valueResult.state)
) {
return realtimeSuccess(
Object.freeze({
kind: "TERMINAL" as const,
attempts,
state: valueResult.state,
value: valueResult.value,
}),
);
}
const delay = successDelay(policy, random);
if (delay === null) {
return pollFailure("PROTOCOL_MISMATCH");
}
nextDelayMs = delay;
}
} finally {
input.signal?.removeEventListener("abort", onCallerAbort);
safelyUnsubscribe(unsubscribeVisibility);
safelyUnsubscribe(unsubscribeOnline);
if (activeController === controller) {
activeController = null;
}
if (generation === runGeneration) {
state = pendingWork === 0 ? "IDLE" : "DRAINING";
}
}
}
function trackWork<WorkValue>(
work: Promise<WorkValue>,
workGeneration: number,
): Promise<WorkValue> {
pendingWork += 1;
void work.then(
() => releaseWork(workGeneration),
() => releaseWork(workGeneration),
);
return work;
}
function releaseWork(workGeneration: number): void {
pendingWork = Math.max(0, pendingWork - 1);
if (
pendingWork === 0 &&
state === "DRAINING" &&
generation === workGeneration
) {
state = "IDLE";
}
}
function close(): void {
if (state === "CLOSED") return;
state = "CLOSED";
generation += 1;
activeController?.abort();
}
return Object.freeze({
run,
getState: () => state,
close,
});
}
function currentFailure(
requested: RealtimeFailureKind | null,
state: "CLOSED" | "DRAINING" | "IDLE" | "RUNNING",
runGeneration: number,
currentGeneration: number,
isCurrent: () => boolean,
environment: BoundedPollEnvironment,
): RealtimeFailureKind | null {
if (requested) return requested;
if (
state === "CLOSED" ||
state === "DRAINING" ||
runGeneration !== currentGeneration
) {
return "CLOSED";
}
if (!safeIsCurrent(isCurrent)) return "SCOPE_FENCED";
if (safeVisibility(environment) !== "VISIBLE") return "ABORTED";
if (safeOnline(environment) !== true) return "OFFLINE";
return null;
}
type TaskOutcome<Value> =
| Readonly<{ kind: "VALUE"; value: Value }>
| Readonly<{ kind: "THREW" }>
| Readonly<{ kind: "ABORTED" }>;
type LeaseTaskOutcome<Value> =
| TaskOutcome<Value>
| Readonly<{ kind: "LEASE_EXPIRED" }>
| Readonly<{ kind: "CLOCK_FAILED" }>;
async function awaitTaskOrAbort<Value>(
task: () => Promise<Value>,
signal: AbortSignal,
): Promise<TaskOutcome<Value>> {
if (signal.aborted) return Object.freeze({ kind: "ABORTED" });
let removeAbortListener: () => void = () => undefined;
const aborted = new Promise<Readonly<{ kind: "ABORTED" }>>(
(resolve) => {
const onAbort = () => resolve(Object.freeze({ kind: "ABORTED" }));
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () =>
signal.removeEventListener("abort", onAbort);
if (signal.aborted) onAbort();
},
);
if (signal.aborted) {
removeAbortListener();
return Object.freeze({ kind: "ABORTED" });
}
let taskPromise: Promise<Value>;
try {
taskPromise = task();
} catch {
removeAbortListener();
return Object.freeze({ kind: "THREW" });
}
const completed = taskPromise.then<
TaskOutcome<Value>,
TaskOutcome<Value>
>(
(value) => Object.freeze({ kind: "VALUE", value }),
() => Object.freeze({ kind: "THREW" }),
);
try {
return await Promise.race([completed, aborted]);
} finally {
removeAbortListener();
}
}
async function awaitWithinLease<Value>(
task: () => Promise<Value>,
remainingMs: number,
clock: ClockPort,
signal: AbortSignal,
onLeaseExpired: () => void,
): Promise<LeaseTaskOutcome<Value>> {
if (!Number.isFinite(remainingMs) || remainingMs <= 0) {
onLeaseExpired();
return Object.freeze({ kind: "LEASE_EXPIRED" });
}
if (signal.aborted) return Object.freeze({ kind: "ABORTED" });
const deadlineController = new AbortController();
let resolveInterruption:
| ((outcome: LeaseTaskOutcome<Value>) => void)
| undefined;
let removeAbortListener: () => void = () => undefined;
let interruptionSettled = false;
const finishInterruption = (
outcome: LeaseTaskOutcome<Value>,
): boolean => {
if (interruptionSettled) return false;
interruptionSettled = true;
resolveInterruption?.(outcome);
return true;
};
const interrupted = new Promise<LeaseTaskOutcome<Value>>((resolve) => {
resolveInterruption = resolve;
const onAbort = () =>
finishInterruption(Object.freeze({ kind: "ABORTED" }));
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () =>
signal.removeEventListener("abort", onAbort);
if (signal.aborted) onAbort();
});
if (signal.aborted) {
removeAbortListener();
return Object.freeze({ kind: "ABORTED" });
}
let deadlineSleep: Promise<void>;
try {
deadlineSleep = clock.sleep(
remainingMs,
deadlineController.signal,
);
} catch {
removeAbortListener();
deadlineController.abort();
return Object.freeze({ kind: "CLOCK_FAILED" });
}
const deadline = deadlineSleep.then(
() => {
if (deadlineController.signal.aborted) return;
if (
finishInterruption(
Object.freeze({ kind: "LEASE_EXPIRED" }),
)
) {
onLeaseExpired();
}
},
() => {
if (!deadlineController.signal.aborted) {
finishInterruption(
Object.freeze({ kind: "CLOCK_FAILED" }),
);
}
},
);
if (signal.aborted) {
removeAbortListener();
deadlineController.abort();
void deadline;
return Object.freeze({ kind: "ABORTED" });
}
let taskPromise: Promise<Value>;
try {
taskPromise = task();
} catch {
taskPromise = Promise.reject(new Error("Task failed."));
}
const completed = taskPromise.then<
LeaseTaskOutcome<Value>,
LeaseTaskOutcome<Value>
>(
(value) => Object.freeze({ kind: "VALUE", value }),
() => Object.freeze({ kind: "THREW" }),
);
try {
const outcome = await Promise.race([completed, interrupted]);
void deadline;
return outcome;
} finally {
removeAbortListener();
deadlineController.abort();
}
}
function parseAttemptResult<Value>(
result: unknown,
): BoundedPollAttemptResult<Value> | null {
const outer = snapshotDataRecord(result, [
["error", "ok"],
["ok", "value"],
]);
if (!outer) return null;
if (outer.ok === false) {
const error = snapshotDataRecord(outer.error, [
["kind", "operation", "retryable"],
["kind", "operation", "retryable", "retryAfterMs"],
]);
if (
!error ||
!REALTIME_FAILURE_KINDS.includes(
error.kind as RealtimeFailureKind,
) ||
error.operation !== "POLL" ||
typeof error.retryable !== "boolean" ||
(Object.hasOwn(error, "retryAfterMs") &&
(!Number.isSafeInteger(error.retryAfterMs) ||
(error.retryAfterMs as number) < 0))
) {
return null;
}
const canonicalError = Object.freeze({
kind: error.kind as RealtimeFailureKind,
operation: "POLL" as const,
retryable: error.retryable,
...(Object.hasOwn(error, "retryAfterMs")
? { retryAfterMs: error.retryAfterMs as number }
: {}),
});
return Object.freeze({
ok: false as const,
error: canonicalError,
});
}
if (outer.ok !== true) return null;
const value = snapshotDataRecord(outer.value, [
["kind", "responseBytes"],
["kind", "responseBytes", "state", "value"],
["kind", "responseBytes", "value"],
]);
if (
!value ||
!Number.isSafeInteger(value.responseBytes) ||
(value.responseBytes as number) < 0
) {
return null;
}
if (value.kind === "UNCHANGED") {
return value.responseBytes === 0
? Object.freeze({
ok: true as const,
value: Object.freeze({
kind: "UNCHANGED" as const,
responseBytes: 0 as const,
}),
})
: null;
}
if (
value.kind !== "VALUE" ||
(Object.hasOwn(value, "state") &&
(typeof value.state !== "string" ||
!SAFE_STATE.test(value.state)))
) {
return null;
}
return Object.freeze({
ok: true as const,
value: Object.freeze({
kind: "VALUE" as const,
value: value.value as Value,
responseBytes: value.responseBytes as number,
...(Object.hasOwn(value, "state")
? { state: value.state as string }
: {}),
}),
});
}
function snapshotDataRecord(
value: unknown,
allowedKeySets: readonly (readonly string[])[],
): Readonly<Record<string, unknown>> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
try {
if (Object.getPrototypeOf(value) !== Object.prototype) {
return null;
}
const keys = Reflect.ownKeys(value);
if (keys.some((key) => typeof key !== "string")) return null;
const sortedKeys = (keys as string[]).sort();
if (
!allowedKeySets.some((allowed) => {
const sortedAllowed = [...allowed].sort();
return (
sortedKeys.length === sortedAllowed.length &&
sortedKeys.every(
(key, index) => key === sortedAllowed[index],
)
);
})
) {
return null;
}
const descriptors = Object.getOwnPropertyDescriptors(value);
const snapshot: Record<string, unknown> = {};
for (const key of sortedKeys) {
const descriptor = descriptors[key];
if (!descriptor || !Object.hasOwn(descriptor, "value")) {
return null;
}
snapshot[key] = descriptor.value;
}
return Object.freeze(snapshot);
} catch {
return null;
}
}
function retryDelay(
policy: PollLeasePolicy,
consecutiveFailures: number,
retryAfterMs: number | undefined,
random: () => number,
): number | null {
const sample = safeRandom(random);
if (sample === null) return null;
if (
retryAfterMs !== undefined &&
(!Number.isSafeInteger(retryAfterMs) ||
retryAfterMs < 0 ||
retryAfterMs > policy.maxIntervalMs)
) {
return null;
}
const ceiling = Math.min(
policy.maxIntervalMs,
policy.minimumIntervalMs * 2 ** Math.max(0, consecutiveFailures - 1),
);
return Math.max(
policy.minimumIntervalMs,
Math.floor(ceiling * sample),
retryAfterMs ?? 0,
);
}
function isPollRetryableFailureKind(
kind: RealtimeFailureKind,
): boolean {
return POLL_RETRYABLE_FAILURE_KINDS.includes(
kind as (typeof POLL_RETRYABLE_FAILURE_KINDS)[number],
);
}
function isPollRetryAfterFailureKind(
kind: RealtimeFailureKind,
): boolean {
return POLL_RETRY_AFTER_FAILURE_KINDS.includes(
kind as (typeof POLL_RETRY_AFTER_FAILURE_KINDS)[number],
);
}
function successDelay(
policy: PollLeasePolicy,
random: () => number,
): number | null {
const sample = safeRandom(random);
if (sample === null) return null;
const spread = Math.floor(policy.successIntervalMs * 0.1);
return Math.min(
policy.maxIntervalMs,
Math.max(
policy.minimumIntervalMs,
policy.successIntervalMs -
spread +
Math.floor(2 * spread * sample),
),
);
}
function safeRandom(random: () => number): number | null {
try {
const value = random();
return Number.isFinite(value) && value >= 0 && value < 1
? value
: null;
} catch {
return null;
}
}
function safeNow(clock: ClockPort): number | null {
try {
const value = clock.now();
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
function safeIsCurrent(isCurrent: () => boolean): boolean {
try {
return isCurrent() === true;
} catch {
return false;
}
}
function safeVisibility(
environment: BoundedPollEnvironment,
): "HIDDEN" | "VISIBLE" | null {
try {
const value = environment.visibility();
return value === "HIDDEN" || value === "VISIBLE" ? value : null;
} catch {
return null;
}
}
function safeOnline(
environment: BoundedPollEnvironment,
): boolean | null {
try {
const value = environment.online();
return typeof value === "boolean" ? value : null;
} catch {
return null;
}
}
function safelyUnsubscribe(
unsubscribe: (() => void) | undefined,
): void {
try {
unsubscribe?.();
} catch {
// Lifecycle cleanup remains terminal even for a throwing host.
}
}
function pollFailure(
kind: RealtimeFailureKind,
retryable = false,
): BoundedPollResult<never> {
return realtimeFailure(kind, "POLL", retryable);
}
function applyFailure(): BoundedPollResult<never> {
return realtimeFailure("APPLY_FAILED", "APPLY", false);
}
+10
View File
@@ -0,0 +1,10 @@
export {
createBoundedPollCoordinator,
type BoundedPollAttemptFailure,
type BoundedPollAttemptResult,
type BoundedPollCoordinator,
type BoundedPollCoordinatorDependencies,
type BoundedPollEnvironment,
type BoundedPollResult,
type BoundedPollRunInput,
} from "./bounded-poll-coordinator.ts";
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
export const REALTIME_RECONNECT_CEILINGS = Object.freeze({
drainTimeoutMs: 2_000,
maxAttempts: 10,
maxDrainTimeoutMs: 30_000,
maxElapsedMs: 5 * 60 * 1_000,
maxDelayMs: 60_000,
maxStableOpenMs: 60_000,
});
export type ReconnectPolicy = Readonly<{
baseDelayMs: number;
maxDelayMs: number;
maxAttempts: number;
maxElapsedMs: number;
stableOpenMs: number;
}>;
const RECONNECT_POLICY_KEYS = Object.freeze([
"baseDelayMs",
"maxDelayMs",
"maxAttempts",
"maxElapsedMs",
"stableOpenMs",
] as const);
export type ReconnectDelayInput = Readonly<{
policy: ReconnectPolicy;
/**
* Zero-based number of the reconnect that is about to be scheduled.
*/
attemptIndex: number;
remainingElapsedMs: number;
random: () => number;
/**
* Relative delay required by Retry-After, SSE retry or another validated
* protocol hint. It is a lower bound, never a value to clamp downward.
*/
serverNotBeforeMs?: number | null;
}>;
export function defineReconnectPolicy(
input: ReconnectPolicy,
): ReconnectPolicy {
const snapshot = snapshotPolicy(input);
if (
!snapshot ||
!positiveInteger(snapshot.baseDelayMs) ||
!positiveInteger(snapshot.maxDelayMs) ||
snapshot.baseDelayMs > snapshot.maxDelayMs ||
snapshot.maxDelayMs >
REALTIME_RECONNECT_CEILINGS.maxDelayMs ||
!positiveInteger(snapshot.maxAttempts) ||
snapshot.maxAttempts >
REALTIME_RECONNECT_CEILINGS.maxAttempts ||
!positiveInteger(snapshot.maxElapsedMs) ||
snapshot.maxElapsedMs >
REALTIME_RECONNECT_CEILINGS.maxElapsedMs ||
!positiveInteger(snapshot.stableOpenMs) ||
snapshot.stableOpenMs >
REALTIME_RECONNECT_CEILINGS.maxStableOpenMs
) {
throw new TypeError("Invalid realtime reconnect policy.");
}
return Object.freeze(snapshot);
}
/**
* Full-jitter exponential backoff with a server-provided not-before floor.
* `null` means the attempt budget cannot safely admit another delay.
*/
export function calculateReconnectDelay(
input: ReconnectDelayInput,
): number | null {
const { policy } = input;
if (
!Number.isSafeInteger(input.attemptIndex) ||
input.attemptIndex < 0 ||
input.attemptIndex >= policy.maxAttempts ||
!Number.isFinite(input.remainingElapsedMs) ||
input.remainingElapsedMs <= 0
) {
return null;
}
let sample: number;
try {
sample = input.random();
} catch {
return null;
}
if (!Number.isFinite(sample) || sample < 0 || sample >= 1) {
return null;
}
const exponentialCeiling = Math.min(
policy.maxDelayMs,
policy.baseDelayMs * 2 ** input.attemptIndex,
);
const localDelay = Math.floor(exponentialCeiling * sample);
const serverNotBeforeMs = input.serverNotBeforeMs ?? 0;
if (
!Number.isSafeInteger(serverNotBeforeMs) ||
serverNotBeforeMs < 0 ||
serverNotBeforeMs > policy.maxDelayMs
) {
return null;
}
const effectiveDelay = Math.max(localDelay, serverNotBeforeMs);
return effectiveDelay >= input.remainingElapsedMs
? null
: effectiveDelay;
}
export function reconnectBudgetRemaining(
policy: ReconnectPolicy,
startedAtMs: number,
nowMs: number,
): number {
if (
!Number.isFinite(startedAtMs) ||
!Number.isFinite(nowMs) ||
nowMs < startedAtMs
) {
return 0;
}
return Math.max(0, policy.maxElapsedMs - (nowMs - startedAtMs));
}
export function isReconnectAttemptResetEligible(input: Readonly<{
policy: ReconnectPolicy;
openedAtMs: number;
nowMs: number;
observedValidHeartbeatOrEvent: boolean;
}>): boolean {
if (input.observedValidHeartbeatOrEvent) return true;
return (
Number.isFinite(input.openedAtMs) &&
Number.isFinite(input.nowMs) &&
input.nowMs - input.openedAtMs >= input.policy.stableOpenMs
);
}
/**
* Parses the HTTP Retry-After delay without applying a runtime ceiling.
* Callers must reject a value that exceeds their remaining/max-delay budget.
*/
export function parseRetryAfterDelay(
value: string | null | undefined,
nowEpochMs: number,
): number | null {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > 128 ||
!Number.isFinite(nowEpochMs)
) {
return null;
}
const normalized = value.trim();
if (/^\d+$/u.test(normalized)) {
const seconds = Number(normalized);
return Number.isSafeInteger(seconds) &&
seconds <= Math.floor(Number.MAX_SAFE_INTEGER / 1_000)
? seconds * 1_000
: null;
}
if (
!/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/u.test(
normalized,
)
) {
return null;
}
const timestamp = Date.parse(normalized);
return Number.isFinite(timestamp)
? Math.max(0, timestamp - nowEpochMs)
: null;
}
function positiveInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
function snapshotPolicy(input: unknown): ReconnectPolicy | null {
if (
!input ||
typeof input !== "object" ||
Array.isArray(input)
) {
return null;
}
try {
if (Object.getPrototypeOf(input) !== Object.prototype) {
return null;
}
const ownKeys = Reflect.ownKeys(input);
if (
ownKeys.length !== RECONNECT_POLICY_KEYS.length ||
RECONNECT_POLICY_KEYS.some(
(key) => !ownKeys.includes(key),
)
) {
return null;
}
const descriptors = Object.getOwnPropertyDescriptors(input);
if (
RECONNECT_POLICY_KEYS.some((key) => {
const descriptor = descriptors[key];
return !descriptor || !Object.hasOwn(descriptor, "value");
})
) {
return null;
}
return {
baseDelayMs: descriptors.baseDelayMs!.value as number,
maxDelayMs: descriptors.maxDelayMs!.value as number,
maxAttempts: descriptors.maxAttempts!.value as number,
maxElapsedMs: descriptors.maxElapsedMs!.value as number,
stableOpenMs: descriptors.stableOpenMs!.value as number,
};
} catch {
return null;
}
}
+278
View File
@@ -0,0 +1,278 @@
import type {
RealtimeTransportEventOutcome,
} from "../../application/ports/realtime/event-authority.ts";
import type {
RealtimeFailure,
RealtimeFailureKind,
RealtimeOperation,
RealtimeResult,
} from "../../application/ports/realtime/shared.ts";
import {
REALTIME_FAILURE_KINDS,
REALTIME_OPERATIONS,
} from "../../application/ports/realtime/shared.ts";
import {
isCanonicalRealtimeSequence,
isRealtimeOpaqueIdentifier,
isRealtimeResumeCursor,
} from "../../contracts/realtime-events.ts";
export type {
RealtimeFailure,
RealtimeFailureKind,
RealtimeOperation,
RealtimeResult,
} from "../../application/ports/realtime/shared.ts";
const DEFAULT_RETRYABLE = new Set<RealtimeFailureKind>([
"OFFLINE",
"CONNECT_TIMEOUT",
"IDLE_TIMEOUT",
"RATE_LIMITED",
"PROVIDER_UNAVAILABLE",
]);
const FAILURE_KINDS = new Set<unknown>(REALTIME_FAILURE_KINDS);
const OPERATIONS = new Set<unknown>(REALTIME_OPERATIONS);
export type RealtimeDataSnapshot = Readonly<{
keys: readonly string[];
values: Readonly<Record<string, unknown>>;
frozen: boolean;
}>;
export function realtimeSuccess<Value>(
value: Value,
): Extract<RealtimeResult<Value>, { ok: true }> {
return Object.freeze({ ok: true, value });
}
export function realtimeFailure(
kind: RealtimeFailureKind,
operation: RealtimeOperation,
retryable = DEFAULT_RETRYABLE.has(kind),
): Extract<RealtimeResult<never>, { ok: false }> {
return Object.freeze({
ok: false,
error: Object.freeze({
kind,
operation,
retryable,
} satisfies RealtimeFailure),
});
}
export function isRealtimeFailure(
value: unknown,
): value is RealtimeFailure {
return parseRealtimeFailure(value, true) !== null;
}
/**
* Captures an external result through own data descriptors exactly once and
* returns a new canonical value. Callers that need to use the validated fields
* must use this returned snapshot rather than reading the source again.
*/
export function snapshotRealtimeResult<Value>(
value: unknown,
isValue: (candidate: unknown) => candidate is Value,
): RealtimeResult<Value> | null {
return parseRealtimeResult(value, isValue, false);
}
export function isRealtimeResult<Value>(
value: unknown,
isValue: (candidate: unknown) => candidate is Value,
): value is RealtimeResult<Value> {
return parseRealtimeResult(value, isValue, true) !== null;
}
export function isRealtimeTransportEventOutcome(
value: unknown,
): value is RealtimeTransportEventOutcome {
const snapshot = captureRealtimeDataSnapshot(value);
if (!snapshot || !snapshot.frozen) {
return false;
}
if (snapshot.values.kind === "CONTINUE") {
return hasExactSnapshotKeys(snapshot, ["kind"]);
}
if (
snapshot.values.kind !== "RECOVERY_COMMITTED" ||
!hasExactSnapshotKeys(snapshot, [
"checkpoint",
"kind",
"streamId",
]) ||
typeof snapshot.values.streamId !== "string"
) {
return false;
}
const checkpoint = captureRealtimeDataSnapshot(
snapshot.values.checkpoint,
);
return (
checkpoint !== null &&
checkpoint.frozen &&
hasExactSnapshotKeys(checkpoint, [
"lastAppliedSequence",
"recoveryMode",
"resumeCursor",
"streamEpoch",
]) &&
isRealtimeOpaqueIdentifier(snapshot.values.streamId) &&
isRealtimeOpaqueIdentifier(checkpoint.values.streamEpoch) &&
isCanonicalRealtimeSequence(
checkpoint.values.lastAppliedSequence,
) &&
(checkpoint.values.recoveryMode === "CURSOR"
? isRealtimeResumeCursor(checkpoint.values.resumeCursor)
: (checkpoint.values.recoveryMode === "SNAPSHOT_ONLY" ||
checkpoint.values.recoveryMode === "SESSION_REBUILD") &&
checkpoint.values.resumeCursor === null)
);
}
/**
* Reads a plain record without invoking property accessors. Symbol keys,
* inherited shapes, non-enumerable fields and accessors are rejected. The
* returned null-prototype value map is immutable and detached from later
* property reads on the source object.
*/
export function captureRealtimeDataSnapshot(
value: unknown,
): RealtimeDataSnapshot | null {
try {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value)
) {
return null;
}
const prototype = Object.getPrototypeOf(value);
if (
prototype !== Object.prototype &&
prototype !== null
) {
return null;
}
const extensible = Object.isExtensible(value);
const descriptors = Object.getOwnPropertyDescriptors(value);
const ownKeys = Reflect.ownKeys(descriptors);
if (ownKeys.some((key) => typeof key !== "string")) {
return null;
}
const keys = (ownKeys as string[]).sort();
const values = Object.create(null) as Record<string, unknown>;
let frozen = !extensible;
for (const key of keys) {
const descriptor = descriptors[key];
if (
!descriptor ||
!Object.hasOwn(descriptor, "value") ||
descriptor.enumerable !== true
) {
return null;
}
Object.defineProperty(values, key, {
configurable: false,
enumerable: true,
value: descriptor.value,
writable: false,
});
frozen =
frozen &&
descriptor.configurable === false &&
descriptor.writable === false;
}
return Object.freeze({
keys: Object.freeze(keys),
values: Object.freeze(values),
frozen,
});
} catch {
return null;
}
}
function parseRealtimeResult<Value>(
value: unknown,
isValue: (candidate: unknown) => candidate is Value,
requireFrozenSource: boolean,
): RealtimeResult<Value> | null {
const snapshot = captureRealtimeDataSnapshot(value);
if (
!snapshot ||
(requireFrozenSource && !snapshot.frozen)
) {
return null;
}
if (
snapshot.values.ok === true &&
hasExactSnapshotKeys(snapshot, ["ok", "value"])
) {
let accepted: boolean;
try {
accepted = isValue(snapshot.values.value);
} catch {
return null;
}
return accepted
? realtimeSuccess(snapshot.values.value as Value)
: null;
}
if (
snapshot.values.ok !== false ||
!hasExactSnapshotKeys(snapshot, ["error", "ok"])
) {
return null;
}
const failure = parseRealtimeFailure(
snapshot.values.error,
requireFrozenSource,
);
return failure
? realtimeFailure(
failure.kind,
failure.operation,
failure.retryable,
)
: null;
}
function parseRealtimeFailure(
value: unknown,
requireFrozenSource: boolean,
): RealtimeFailure | null {
const snapshot = captureRealtimeDataSnapshot(value);
if (
!snapshot ||
(requireFrozenSource && !snapshot.frozen) ||
!hasExactSnapshotKeys(snapshot, [
"kind",
"operation",
"retryable",
]) ||
!FAILURE_KINDS.has(snapshot.values.kind) ||
!OPERATIONS.has(snapshot.values.operation) ||
typeof snapshot.values.retryable !== "boolean"
) {
return null;
}
return Object.freeze({
kind: snapshot.values.kind as RealtimeFailureKind,
operation: snapshot.values.operation as RealtimeOperation,
retryable: snapshot.values.retryable,
});
}
function hasExactSnapshotKeys(
snapshot: RealtimeDataSnapshot,
expectedKeys: readonly string[],
): boolean {
const expected = [...expectedKeys].sort();
return (
snapshot.keys.length === expected.length &&
snapshot.keys.every((key, index) => key === expected[index])
);
}
@@ -0,0 +1,738 @@
import type { ClockPort } from "../../../application/ports/clock-port.ts";
import type {
RealtimeFailureKind,
RealtimeOperation,
RealtimeResult,
} from "../../../application/ports/realtime/shared.ts";
import type {
RealtimeTransportEventOutcome,
} from "../../../application/ports/realtime/event-authority.ts";
import {
REALTIME_TRANSPORT_CONTINUE,
} from "../../../application/ports/realtime/event-authority.ts";
import { isRealtimeResumeCursor } from "../../../contracts/realtime-events.ts";
import { systemClock } from "../../platform/system-clock.ts";
import { parseRetryAfterDelay } from "../reconnect-policy.ts";
import {
isRealtimeResult,
isRealtimeTransportEventOutcome,
realtimeFailure,
realtimeSuccess,
} from "../result.ts";
import {
createIncrementalSseParser,
type ParsedSseEvent,
type SseParserItem,
type SseParserLimits,
} from "./sse-parser.ts";
export type SseRecoveryMode =
| "CURSOR"
| "SESSION_REBUILD"
| "SNAPSHOT_ONLY";
export type FetchSseClosedOutcome =
| Readonly<{
kind: "EOF";
incompleteEventDiscarded: boolean;
retryHintMs: number | null;
}>
| Readonly<{ kind: "NO_RECONNECT" }>
| Extract<
RealtimeTransportEventOutcome,
{ kind: "RECOVERY_COMMITTED" }
>;
export type SseInboundEventOutcome =
RealtimeTransportEventOutcome;
export const SSE_CONTINUE: SseInboundEventOutcome =
REALTIME_TRANSPORT_CONTINUE;
export type FetchSseReadInput = Readonly<{
resumeCursor: string | null;
signal?: AbortSignal;
/**
* Runs after the response and stream contract are validated but before any
* event bytes are consumed. A reconnect bridge can hold this gate until the
* exact recovery checkpoint's replay barrier is confirmed.
*/
onOpen?(
signal: AbortSignal,
):
| RealtimeResult<void>
| Promise<RealtimeResult<void>>;
onEvent(
event: ParsedSseEvent,
signal: AbortSignal,
):
| RealtimeResult<SseInboundEventOutcome>
| Promise<RealtimeResult<SseInboundEventOutcome>>;
onComment?: () => void;
onRetryHint?: (retryMs: number) => void;
}>;
export type FetchSseConnection = Readonly<{
read(
input: FetchSseReadInput,
): Promise<RealtimeResult<FetchSseClosedOutcome>>;
close(): void;
}>;
export type FetchSseConnectionDependencies = Readonly<{
endpoint: string;
applicationOrigin: string;
recoveryMode: SseRecoveryMode;
fetcher?: typeof fetch;
clock?: ClockPort;
parserLimits?: Partial<SseParserLimits>;
connectTimeoutMs?: number;
idleTimeoutMs?: number;
maxCursorBytes?: number;
maxRetryAfterMs?: number;
}>;
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
const DEFAULT_IDLE_TIMEOUT_MS = 45_000;
const DEFAULT_MAX_CURSOR_BYTES = 1_024;
const DEFAULT_MAX_RETRY_AFTER_MS = 60_000;
const MAX_CONNECT_TIMEOUT_MS = 30_000;
const MAX_IDLE_TIMEOUT_MS = 120_000;
const MAX_CURSOR_BYTES = 1_024;
const READER_CANCEL_TIMEOUT_MS = 2_000;
export function createFetchSseConnection(
dependencies: FetchSseConnectionDependencies,
): FetchSseConnection {
const endpoint = fixedEndpoint(
dependencies.endpoint,
dependencies.applicationOrigin,
);
const fetcher = dependencies.fetcher ?? fetch;
const clock = dependencies.clock ?? systemClock;
const connectTimeoutMs =
dependencies.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
const idleTimeoutMs =
dependencies.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
const maxCursorBytes =
dependencies.maxCursorBytes ?? DEFAULT_MAX_CURSOR_BYTES;
const maxRetryAfterMs =
dependencies.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS;
validateDependencies(
dependencies.recoveryMode,
connectTimeoutMs,
idleTimeoutMs,
maxCursorBytes,
maxRetryAfterMs,
);
// Validate immutable parser policy at factory construction, before network
// side effects. A fresh parser is still created for every physical attempt.
createIncrementalSseParser(dependencies.parserLimits);
let closed = false;
let active = false;
let activeController: AbortController | null = null;
let activeReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
async function read(
input: FetchSseReadInput,
): Promise<RealtimeResult<FetchSseClosedOutcome>> {
if (closed) return failed("CLOSED", "CONNECT", false);
if (active) {
return failed("PROTOCOL_MISMATCH", "CONNECT", false);
}
if (
!validResumeCursor(
input.resumeCursor,
dependencies.recoveryMode,
maxCursorBytes,
)
) {
return failed("PROTOCOL_MISMATCH", "CONNECT", false);
}
if (input.signal?.aborted) {
return failed("ABORTED", "CONNECT", false);
}
active = true;
const controller = new AbortController();
activeController = controller;
const onCallerAbort = () => controller.abort();
input.signal?.addEventListener("abort", onCallerAbort, {
once: true,
});
if (input.signal?.aborted) onCallerAbort();
try {
const request = timed(
Promise.resolve().then(() =>
fetcher(endpoint.href, {
method: "GET",
credentials: "same-origin",
redirect: "error",
cache: "no-store",
referrerPolicy: "no-referrer",
headers: {
Accept: "text/event-stream",
...(input.resumeCursor === null
? {}
: { "Last-Event-ID": input.resumeCursor }),
},
signal: controller.signal,
}),
),
connectTimeoutMs,
clock,
controller.signal,
);
const responseResult = await request;
if (responseResult.kind === "CLOCK_FAILED") {
controller.abort();
return failed("PROVIDER_UNAVAILABLE", "CONNECT", true);
}
if (responseResult.kind === "ABORTED") {
return failed("ABORTED", "CONNECT", false);
}
if (responseResult.kind === "TIMEOUT") {
controller.abort();
return failed("CONNECT_TIMEOUT", "CONNECT", true);
}
if (responseResult.kind === "REJECTED") {
return failed(
controller.signal.aborted ? "ABORTED" : "OFFLINE",
"CONNECT",
!controller.signal.aborted,
);
}
const response = responseResult.value;
if (response.redirected) {
return failed("PROTOCOL_MISMATCH", "CONNECT", false);
}
if (response.status === 204) {
return succeeded(Object.freeze({ kind: "NO_RECONNECT" }));
}
if (response.status !== 200) {
const responseObservedAt = readClockNow(clock);
if (responseObservedAt === null) {
controller.abort();
return failed(
"PROVIDER_UNAVAILABLE",
"CONNECT",
true,
);
}
return responseFailure(
response,
responseObservedAt,
maxRetryAfterMs,
input.onRetryHint,
);
}
if (!isEventStreamContentType(response.headers.get("content-type"))) {
return failed("PROTOCOL_MISMATCH", "CONNECT", false);
}
if (!response.body) {
return failed("MALFORMED_EVENT", "RECEIVE", false);
}
const parser = createIncrementalSseParser(
dependencies.parserLimits,
);
const reader = response.body.getReader();
activeReader = reader;
let retryHintMs: number | null = null;
if (input.onOpen) {
let opening: Promise<RealtimeResult<void>>;
try {
opening = Promise.resolve(input.onOpen(controller.signal));
} catch {
await cancelReader(reader, clock, controller);
return failed(
"PROVIDER_UNAVAILABLE",
"CONNECT",
false,
);
}
const opened = await timed(
opening,
connectTimeoutMs,
clock,
controller.signal,
);
if (opened.kind !== "VALUE") {
await cancelReader(reader, clock, controller);
if (opened.kind === "ABORTED") {
return failed("ABORTED", "CONNECT", false);
}
if (opened.kind === "TIMEOUT") {
return failed("CONNECT_TIMEOUT", "CONNECT", true);
}
return failed(
"PROVIDER_UNAVAILABLE",
"CONNECT",
false,
);
}
if (!isRealtimeResult(opened.value, isUndefined)) {
await cancelReader(reader, clock, controller);
return failed(
"PROTOCOL_MISMATCH",
"CONNECT",
false,
);
}
if (!opened.value.ok) {
await cancelReader(reader, clock, controller);
return opened.value;
}
}
async function handleParserItems(
items: readonly SseParserItem[],
): Promise<RealtimeResult<FetchSseClosedOutcome> | null> {
for (const item of items) {
if (item.kind === "COMMENT") {
safelyNotify(input.onComment);
continue;
}
if (item.kind === "RETRY") {
retryHintMs = item.retryMs;
safelyNotify(input.onRetryHint, item.retryMs);
continue;
}
if (
dependencies.recoveryMode === "CURSOR" &&
(!item.hasExplicitId ||
!item.id ||
!isRealtimeResumeCursor(item.id) ||
new TextEncoder().encode(item.id).byteLength >
maxCursorBytes)
) {
await cancelReader(reader, clock, controller);
return failed("PROTOCOL_MISMATCH", "DECODE", false);
}
if (
dependencies.recoveryMode !== "CURSOR" &&
(item.hasExplicitId || item.id !== null)
) {
await cancelReader(reader, clock, controller);
return failed("PROTOCOL_MISMATCH", "DECODE", false);
}
let handler: Promise<
RealtimeResult<SseInboundEventOutcome>
>;
try {
handler = Promise.resolve(
input.onEvent(item, controller.signal),
);
} catch {
await cancelReader(reader, clock, controller);
return failed("APPLY_FAILED", "APPLY", false);
}
const handled = await timed(
handler,
idleTimeoutMs,
clock,
controller.signal,
);
if (handled.kind === "ABORTED") {
await cancelReader(reader, clock, controller);
return failed("ABORTED", "APPLY", false);
}
if (handled.kind === "CLOCK_FAILED") {
await cancelReader(reader, clock, controller);
return failed(
"PROVIDER_UNAVAILABLE",
"APPLY",
false,
);
}
if (
handled.kind === "REJECTED" ||
handled.kind === "TIMEOUT"
) {
await cancelReader(reader, clock, controller);
return failed("APPLY_FAILED", "APPLY", false);
}
if (
handled.kind !== "VALUE" ||
!isRealtimeResult(
handled.value,
isRealtimeTransportEventOutcome,
)
) {
await cancelReader(reader, clock, controller);
return failed("APPLY_FAILED", "APPLY", false);
}
if (!handled.value.ok) {
await cancelReader(reader, clock, controller);
return handled.value;
}
if (
handled.value.value.kind === "RECOVERY_COMMITTED"
) {
await cancelReader(reader, clock, controller);
return succeeded(handled.value.value);
}
if (controller.signal.aborted) {
await cancelReader(reader, clock, controller);
return failed("ABORTED", "APPLY", false);
}
}
return null;
}
while (true) {
const readResult = await timed(
reader.read(),
idleTimeoutMs,
clock,
controller.signal,
);
if (readResult.kind === "ABORTED") {
await cancelReader(reader, clock, controller);
return failed("ABORTED", "RECEIVE", false);
}
if (readResult.kind === "CLOCK_FAILED") {
controller.abort();
await cancelReader(reader, clock, controller);
return failed(
"PROVIDER_UNAVAILABLE",
"RECEIVE",
true,
);
}
if (readResult.kind === "TIMEOUT") {
controller.abort();
await cancelReader(reader, clock, controller);
return failed("IDLE_TIMEOUT", "RECEIVE", true);
}
if (readResult.kind === "REJECTED") {
return failed(
controller.signal.aborted ? "ABORTED" : "OFFLINE",
"RECEIVE",
!controller.signal.aborted,
);
}
if (readResult.value.done) {
const finished = parser.finish();
if (!finished.ok) return finished;
const dispatchFailure = await handleParserItems(
finished.value.items,
);
if (dispatchFailure) return dispatchFailure;
return succeeded(
Object.freeze({
kind: "EOF",
incompleteEventDiscarded:
finished.value.incompleteEventDiscarded,
retryHintMs,
}),
);
}
if (!(readResult.value.value instanceof Uint8Array)) {
await cancelReader(reader, clock, controller);
return failed("MALFORMED_EVENT", "DECODE", false);
}
const parsed = parser.push(readResult.value.value);
if (!parsed.ok) {
await cancelReader(reader, clock, controller);
return parsed;
}
const dispatchFailure = await handleParserItems(parsed.value);
if (dispatchFailure) return dispatchFailure;
}
} catch {
return failed(
controller.signal.aborted ? "ABORTED" : "MALFORMED_EVENT",
activeReader ? "RECEIVE" : "CONNECT",
false,
);
} finally {
input.signal?.removeEventListener("abort", onCallerAbort);
controller.abort();
if (activeReader) {
try {
activeReader.releaseLock();
} catch {
// The terminal outcome is already determined.
}
}
activeReader = null;
activeController = null;
active = false;
}
}
function close(): void {
if (closed) return;
closed = true;
activeController?.abort();
if (activeReader) {
void cancelReader(
activeReader,
clock,
activeController ?? undefined,
);
}
}
return Object.freeze({ read, close });
}
function fixedEndpoint(endpoint: string, applicationOrigin: string): URL {
let parsedEndpoint: URL;
let parsedOrigin: URL;
try {
parsedEndpoint = new URL(endpoint);
parsedOrigin = new URL(applicationOrigin);
} catch {
throw new TypeError("SSE endpoint must be an absolute URL.");
}
if (
parsedEndpoint.protocol !== "https:" ||
parsedEndpoint.origin !== parsedOrigin.origin ||
parsedEndpoint.username ||
parsedEndpoint.password ||
parsedEndpoint.search ||
parsedEndpoint.hash
) {
throw new TypeError("SSE endpoint must be fixed same-origin HTTPS.");
}
return parsedEndpoint;
}
function validateDependencies(
recoveryMode: SseRecoveryMode,
connectTimeoutMs: number,
idleTimeoutMs: number,
maxCursorBytes: number,
maxRetryAfterMs: number,
): void {
if (
!["CURSOR", "SESSION_REBUILD", "SNAPSHOT_ONLY"].includes(
recoveryMode,
) ||
!integerWithin(connectTimeoutMs, 1, MAX_CONNECT_TIMEOUT_MS) ||
!integerWithin(idleTimeoutMs, 1, MAX_IDLE_TIMEOUT_MS) ||
!integerWithin(maxCursorBytes, 1, MAX_CURSOR_BYTES) ||
!integerWithin(maxRetryAfterMs, 1, DEFAULT_MAX_RETRY_AFTER_MS)
) {
throw new TypeError("Invalid fetch SSE connection policy.");
}
}
function validResumeCursor(
cursor: string | null,
recoveryMode: SseRecoveryMode,
maxCursorBytes: number,
): boolean {
if (recoveryMode !== "CURSOR") return cursor === null;
if (cursor === null) return true;
return (
typeof cursor === "string" &&
isRealtimeResumeCursor(cursor) &&
new TextEncoder().encode(cursor).byteLength <= maxCursorBytes
);
}
function isEventStreamContentType(value: string | null): boolean {
if (typeof value !== "string" || value.length > 128) return false;
const parts = value.split(";").map((part) => part.trim().toLowerCase());
if (parts[0] !== "text/event-stream") return false;
if (parts.length === 1) return true;
return (
parts.length === 2 &&
/^(?:charset=utf-8|charset="utf-8")$/u.test(parts[1] ?? "")
);
}
function responseFailure(
response: Response,
nowEpochMs: number,
maxRetryAfterMs: number,
onRetryHint: ((retryMs: number) => void) | undefined,
): RealtimeResult<never> {
const status = response.status;
if (status === 401) {
return failed("AUTH_REQUIRED", "CONNECT", false);
}
if (status === 403) {
return failed("FORBIDDEN", "CONNECT", false);
}
if (status === 409 || status === 410) {
return failed("CURSOR_EXPIRED", "CONNECT", false);
}
const retryAfterMs =
status === 429 || status === 503
? parseRetryAfterDelay(
response.headers.get("retry-after"),
nowEpochMs,
)
: null;
const retryHintAccepted =
retryAfterMs !== null && retryAfterMs <= maxRetryAfterMs;
if (retryHintAccepted) {
safelyNotify(onRetryHint, retryAfterMs);
}
if (status === 429) {
return failed("RATE_LIMITED", "CONNECT", retryHintAccepted);
}
if (status === 503) {
return failed(
"PROVIDER_UNAVAILABLE",
"CONNECT",
retryHintAccepted,
);
}
if (status === 502 || status === 504) {
return failed("PROVIDER_UNAVAILABLE", "CONNECT", true);
}
return failed("PROTOCOL_MISMATCH", "CONNECT", false);
}
type TimedResult<Value> =
| Readonly<{ kind: "VALUE"; value: Value }>
| Readonly<{ kind: "REJECTED" }>
| Readonly<{ kind: "ABORTED" }>
| Readonly<{ kind: "CLOCK_FAILED" }>
| Readonly<{ kind: "TIMEOUT" }>;
async function timed<Value>(
operation: Promise<Value>,
timeoutMs: number,
clock: ClockPort,
signal: AbortSignal,
): Promise<TimedResult<Value>> {
if (signal.aborted) {
return Object.freeze({ kind: "ABORTED" });
}
const timer = new AbortController();
let abortListener: (() => void) | undefined;
const operationResult = operation.then<
TimedResult<Value>,
TimedResult<Value>
>(
(value) => Object.freeze({ kind: "VALUE", value }),
() => Object.freeze({ kind: "REJECTED" }),
);
let timeoutResult: Promise<TimedResult<Value>>;
try {
timeoutResult = clock.sleep(timeoutMs, timer.signal).then<
TimedResult<Value>,
TimedResult<Value>
>(
() => Object.freeze({ kind: "TIMEOUT" }),
() =>
Object.freeze({
kind: timer.signal.aborted
? ("ABORTED" as const)
: ("CLOCK_FAILED" as const),
}),
);
} catch {
return Object.freeze({ kind: "CLOCK_FAILED" });
}
const abortedResult = new Promise<TimedResult<Value>>((resolve) => {
abortListener = () =>
resolve(Object.freeze({ kind: "ABORTED" }));
signal.addEventListener("abort", abortListener, { once: true });
if (signal.aborted) abortListener();
});
const result = await Promise.race([
operationResult,
timeoutResult,
abortedResult,
]);
timer.abort();
if (abortListener) {
signal.removeEventListener("abort", abortListener);
}
return result;
}
function readClockNow(clock: ClockPort): number | null {
try {
const value = clock.now();
return Number.isFinite(value) && value >= 0 ? value : null;
} catch {
return null;
}
}
async function cancelReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
clock: ClockPort,
generation?: AbortController,
): Promise<void> {
generation?.abort();
let cancellation: Promise<void>;
try {
cancellation = Promise.resolve(reader.cancel()).then(
() => undefined,
() => undefined,
);
} catch {
return;
}
const timeout = new AbortController();
let timeoutPromise: Promise<void>;
try {
timeoutPromise = clock
.sleep(READER_CANCEL_TIMEOUT_MS, timeout.signal)
.then(
() => undefined,
() => undefined,
);
} catch {
timeoutPromise = Promise.resolve();
}
await Promise.race([cancellation, timeoutPromise]);
timeout.abort();
}
function safelyNotify(
callback: ((value?: never) => void) | undefined,
): void;
function safelyNotify<Value>(
callback: ((value: Value) => void) | undefined,
value: Value,
): void;
function safelyNotify<Value>(
callback: ((value: Value) => void) | (() => void) | undefined,
value?: Value,
): void {
try {
if (callback) callback(value as Value);
} catch {
// Observation and retry-hint consumers are best effort.
}
}
function succeeded<Value>(value: Value): RealtimeResult<Value> {
return realtimeSuccess(value);
}
function failed(
kind: RealtimeFailureKind,
operation: RealtimeOperation,
retryable?: boolean,
): RealtimeResult<never> {
return realtimeFailure(kind, operation, retryable);
}
function integerWithin(
value: number,
minimum: number,
maximum: number,
): boolean {
return (
Number.isSafeInteger(value) &&
value >= minimum &&
value <= maximum
);
}
function isUndefined(value: unknown): value is undefined {
return value === undefined;
}
+19
View File
@@ -0,0 +1,19 @@
export {
createFetchSseConnection,
SSE_CONTINUE,
type FetchSseClosedOutcome,
type FetchSseConnection,
type FetchSseConnectionDependencies,
type FetchSseReadInput,
type SseInboundEventOutcome,
type SseRecoveryMode,
} from "./fetch-sse-connection.ts";
export {
createIncrementalSseParser,
SSE_PARSER_CEILINGS,
type IncrementalSseParser,
type ParsedSseEvent,
type SseParserFinish,
type SseParserItem,
type SseParserLimits,
} from "./sse-parser.ts";
+346
View File
@@ -0,0 +1,346 @@
import type { RealtimeResult } from "../../../application/ports/realtime/shared.ts";
import {
realtimeFailure,
realtimeSuccess,
} from "../result.ts";
export const SSE_PARSER_CEILINGS = Object.freeze({
maxLineBytes: 64 * 1_024,
maxEventBytes: 64 * 1_024,
maxIncompleteBufferBytes: 128 * 1_024,
maxChunkBytes: 256 * 1_024,
maxItemsPerChunk: 256,
maxRetryMs: 60_000,
});
export type SseParserLimits = Readonly<{
maxLineBytes: number;
maxEventBytes: number;
maxIncompleteBufferBytes: number;
maxChunkBytes: number;
maxItemsPerChunk: number;
maxRetryMs: number;
}>;
export type ParsedSseEvent = Readonly<{
kind: "EVENT";
eventType: string;
data: string;
/**
* Standard SSE last-event-ID state. Consumers that require cursor-after-
* effect must additionally require `hasExplicitId` and commit independently.
*/
id: string | null;
hasExplicitId: boolean;
}>;
export type SseParserItem =
| ParsedSseEvent
| Readonly<{ kind: "COMMENT" }>
| Readonly<{ kind: "RETRY"; retryMs: number }>;
export type SseParserFinish = Readonly<{
items: readonly SseParserItem[];
incompleteEventDiscarded: boolean;
}>;
export type IncrementalSseParser = Readonly<{
push(chunk: Uint8Array): RealtimeResult<readonly SseParserItem[]>;
finish(): RealtimeResult<SseParserFinish>;
}>;
export function createIncrementalSseParser(
limits: Partial<SseParserLimits> = {},
): IncrementalSseParser {
const resolved = resolveLimits(limits);
const decoder = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: false,
});
const encoder = new TextEncoder();
let state: "OPEN" | "FAILED" | "FINISHED" = "OPEN";
let atStart = true;
let pendingCarriageReturn = false;
let line = "";
let lineBytes = 0;
let blockBytes = 0;
let dataLines: string[] = [];
let eventType = "";
let lastEventId: string | null = null;
let hasExplicitId = false;
function push(
chunk: Uint8Array,
): RealtimeResult<readonly SseParserItem[]> {
if (state !== "OPEN") {
return realtimeFailure("CLOSED", "DECODE");
}
if (!(chunk instanceof Uint8Array)) {
return fail("MALFORMED_EVENT");
}
if (chunk.byteLength > resolved.maxChunkBytes) {
return fail("EVENT_TOO_LARGE");
}
let text: string;
try {
text = decoder.decode(chunk, { stream: true });
} catch {
return fail("MALFORMED_EVENT");
}
return consumeText(text);
}
function finish(): RealtimeResult<SseParserFinish> {
if (state !== "OPEN") {
return realtimeFailure("CLOSED", "DECODE");
}
let tail: string;
try {
tail = decoder.decode();
} catch {
return fail("MALFORMED_EVENT");
}
const consumed = consumeText(tail);
if (!consumed.ok) return consumed;
const items = [...consumed.value];
if (pendingCarriageReturn) {
pendingCarriageReturn = false;
const processed = processLine(1);
if (!processed.ok) return processed;
if (!appendItems(items, processed.value)) {
return fail("QUEUE_OVERFLOW");
}
}
const incompleteEventDiscarded =
lineBytes > 0 ||
blockBytes > 0 ||
dataLines.length > 0 ||
eventType.length > 0 ||
hasExplicitId;
clearBlock();
line = "";
lineBytes = 0;
state = "FINISHED";
return success(
Object.freeze({
items: Object.freeze(items),
incompleteEventDiscarded,
}),
);
}
function consumeText(
text: string,
): RealtimeResult<readonly SseParserItem[]> {
const items: SseParserItem[] = [];
for (const character of text) {
if (atStart) {
atStart = false;
if (character === "\uFEFF") continue;
}
if (pendingCarriageReturn) {
pendingCarriageReturn = false;
const processed = processLine(character === "\n" ? 2 : 1);
if (!processed.ok) return processed;
if (!appendItems(items, processed.value)) {
return fail("QUEUE_OVERFLOW");
}
if (character === "\n") continue;
}
if (character === "\r") {
pendingCarriageReturn = true;
continue;
}
if (character === "\n") {
const processed = processLine(1);
if (!processed.ok) return processed;
if (!appendItems(items, processed.value)) {
return fail("QUEUE_OVERFLOW");
}
continue;
}
line += character;
lineBytes += encoder.encode(character).byteLength;
if (lineBytes > resolved.maxLineBytes) {
return fail("EVENT_TOO_LARGE");
}
if (
lineBytes + blockBytes >
resolved.maxIncompleteBufferBytes
) {
return fail("EVENT_TOO_LARGE");
}
}
return success(Object.freeze(items));
}
function processLine(
terminatorBytes: number,
): RealtimeResult<readonly SseParserItem[]> {
const currentLine = line;
const currentLineBytes = lineBytes;
line = "";
lineBytes = 0;
if (currentLine.length === 0) {
const items: SseParserItem[] = [];
if (dataLines.length > 0) {
items.push(
Object.freeze({
kind: "EVENT",
eventType: eventType.length > 0 ? eventType : "message",
data: dataLines.join("\n"),
id: lastEventId,
hasExplicitId,
}),
);
}
clearBlock();
return success(Object.freeze(items));
}
if (currentLine.startsWith(":")) {
return success(
Object.freeze([
Object.freeze({ kind: "COMMENT" as const }),
]),
);
}
blockBytes += currentLineBytes + terminatorBytes;
if (blockBytes > resolved.maxEventBytes) {
return fail("EVENT_TOO_LARGE");
}
if (blockBytes > resolved.maxIncompleteBufferBytes) {
return fail("EVENT_TOO_LARGE");
}
const separator = currentLine.indexOf(":");
const field =
separator === -1
? currentLine
: currentLine.slice(0, separator);
let value =
separator === -1 ? "" : currentLine.slice(separator + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "data") {
dataLines.push(value);
return success(Object.freeze([]));
}
if (field === "event") {
eventType = value;
return success(Object.freeze([]));
}
if (field === "id") {
if (!value.includes("\0")) {
lastEventId = value;
hasExplicitId = true;
}
return success(Object.freeze([]));
}
if (field === "retry" && /^\d+$/u.test(value)) {
const retryMs = Number(value);
if (
Number.isSafeInteger(retryMs) &&
retryMs <= resolved.maxRetryMs
) {
return success(
Object.freeze([
Object.freeze({ kind: "RETRY" as const, retryMs }),
]),
);
}
}
return success(Object.freeze([]));
}
function clearBlock(): void {
blockBytes = 0;
dataLines = [];
eventType = "";
hasExplicitId = false;
}
function fail(
kind:
| "EVENT_TOO_LARGE"
| "MALFORMED_EVENT"
| "QUEUE_OVERFLOW",
): RealtimeResult<never> {
state = "FAILED";
line = "";
lineBytes = 0;
clearBlock();
return realtimeFailure(kind, "DECODE");
}
function appendItems(
target: SseParserItem[],
additions: readonly SseParserItem[],
): boolean {
if (
target.length + additions.length >
resolved.maxItemsPerChunk
) {
return false;
}
target.push(...additions);
return true;
}
return Object.freeze({ push, finish });
}
function resolveLimits(
input: Partial<SseParserLimits>,
): SseParserLimits {
const limits = {
maxLineBytes:
input.maxLineBytes ?? SSE_PARSER_CEILINGS.maxLineBytes,
maxEventBytes:
input.maxEventBytes ?? SSE_PARSER_CEILINGS.maxEventBytes,
maxIncompleteBufferBytes:
input.maxIncompleteBufferBytes ??
SSE_PARSER_CEILINGS.maxIncompleteBufferBytes,
maxChunkBytes:
input.maxChunkBytes ?? SSE_PARSER_CEILINGS.maxChunkBytes,
maxItemsPerChunk:
input.maxItemsPerChunk ??
SSE_PARSER_CEILINGS.maxItemsPerChunk,
maxRetryMs: input.maxRetryMs ?? SSE_PARSER_CEILINGS.maxRetryMs,
};
if (
!positiveInteger(limits.maxLineBytes) ||
limits.maxLineBytes > SSE_PARSER_CEILINGS.maxLineBytes ||
!positiveInteger(limits.maxEventBytes) ||
limits.maxEventBytes > SSE_PARSER_CEILINGS.maxEventBytes ||
!positiveInteger(limits.maxIncompleteBufferBytes) ||
limits.maxIncompleteBufferBytes >
SSE_PARSER_CEILINGS.maxIncompleteBufferBytes ||
limits.maxIncompleteBufferBytes < limits.maxEventBytes ||
!positiveInteger(limits.maxChunkBytes) ||
limits.maxChunkBytes > SSE_PARSER_CEILINGS.maxChunkBytes ||
limits.maxChunkBytes < limits.maxEventBytes ||
!positiveInteger(limits.maxItemsPerChunk) ||
limits.maxItemsPerChunk >
SSE_PARSER_CEILINGS.maxItemsPerChunk ||
!positiveInteger(limits.maxRetryMs) ||
limits.maxRetryMs > SSE_PARSER_CEILINGS.maxRetryMs
) {
throw new TypeError("Invalid SSE parser limits.");
}
return Object.freeze(limits);
}
function success<Value>(value: Value): RealtimeResult<Value> {
return realtimeSuccess(value);
}
function positiveInteger(value: number): boolean {
return Number.isSafeInteger(value) && value > 0;
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
export {
createWebSocketConnection,
WEBSOCKET_IMPLEMENTATION_CEILINGS,
type WebSocketClientCeilings,
type WebSocketClosedReceipt,
type WebSocketConnection,
type WebSocketConnectionDependencies,
type WebSocketConnectionObservation,
type WebSocketConnectionSnapshot,
type WebSocketConnectionStatus,
type WebSocketFacade,
type WebSocketInboundEventOutcome,
type WebSocketLocalSendReceipt,
type WebSocketOpenReceipt,
type WebSocketRecoveryRequest,
type WebSocketResumeCheckpoint,
type WebSocketSubscribedReceipt,
type WebSocketSubscriptionRequest,
} from "./websocket-connection.ts";
export {
decodeWebSocketServerFrame,
encodeWebSocketClientFrame,
nextUnsignedSequence,
REALTIME_WEBSOCKET_PROTOCOL,
type WebSocketAdvertisedLimits,
type WebSocketClientCloseFrame,
type WebSocketClientFrame,
type WebSocketCloseCategory,
type WebSocketEventFrame,
type WebSocketHeartbeatAckFrame,
type WebSocketHeartbeatFrame,
type WebSocketProtocolFailure,
type WebSocketProtocolResult,
type WebSocketResetReason,
type WebSocketResetRequiredFrame,
type WebSocketServerCloseFrame,
type WebSocketServerFrame,
type WebSocketSubscribedFrame,
type WebSocketSubscribeFrame,
type WebSocketUnsubscribedFrame,
type WebSocketUnsubscribeFrame,
type WebSocketWelcomeFrame,
} from "./websocket-protocol.ts";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,518 @@
import {
hasDuplicateJsonMembers,
} from "../json-member-scanner.ts";
export const REALTIME_WEBSOCKET_PROTOCOL = "realtime.v1" as const;
export type WebSocketCloseCategory =
| "NORMAL"
| "RESTART"
| "OVERLOADED"
| "AUTH_REQUIRED"
| "FORBIDDEN"
| "PROTOCOL_MISMATCH"
| "CURSOR_RESET"
| "NETWORK_LOST";
export type WebSocketResetReason =
| "CURSOR_EXPIRED"
| "SEQUENCE_GAP"
| "SERVER_RESET"
| "SCOPE_CHANGED";
export type WebSocketAdvertisedLimits = Readonly<{
maxFrameBytes: number;
maxSubscriptions: number;
maxInboundQueueCount: number;
maxInboundQueueBytes: number;
maxOutboundQueueCount: number;
maxOutboundQueueBytes: number;
maxBufferedAmountBytes: number;
maxEventsPerSecond: number;
}>;
export type WebSocketWelcomeFrame = Readonly<{
type: "WELCOME";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
connectionId: string;
heartbeatMs: number;
heartbeatAckTimeoutMs: number;
limits: WebSocketAdvertisedLimits;
}>;
export type WebSocketSubscribedFrame = Readonly<{
type: "SUBSCRIBED";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
subscriptionId: string;
streamEpoch: string;
acceptedCursor: string | null;
nextExpectedSequence: string;
}>;
export type WebSocketUnsubscribedFrame = Readonly<{
type: "UNSUBSCRIBED";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
subscriptionId: string;
}>;
export type WebSocketEventFrame = Readonly<{
type: "EVENT";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
subscriptionId: string;
envelope: Readonly<Record<string, unknown>>;
}>;
export type WebSocketResetRequiredFrame = Readonly<{
type: "RESET_REQUIRED";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
subscriptionId: string;
reason: WebSocketResetReason;
}>;
export type WebSocketHeartbeatAckFrame = Readonly<{
type: "HEARTBEAT_ACK";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
nonce: string;
}>;
export type WebSocketServerCloseFrame = Readonly<{
type: "CLOSE";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
category: WebSocketCloseCategory;
}>;
export type WebSocketServerFrame =
| WebSocketWelcomeFrame
| WebSocketSubscribedFrame
| WebSocketUnsubscribedFrame
| WebSocketEventFrame
| WebSocketResetRequiredFrame
| WebSocketHeartbeatAckFrame
| WebSocketServerCloseFrame;
export type WebSocketSubscribeFrame = Readonly<{
type: "SUBSCRIBE";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
subscriptionId: string;
streamId: string;
cursor: string | null;
scopeBinding: string;
}>;
export type WebSocketUnsubscribeFrame = Readonly<{
type: "UNSUBSCRIBE";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
subscriptionId: string;
}>;
export type WebSocketHeartbeatFrame = Readonly<{
type: "HEARTBEAT";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
nonce: string;
}>;
export type WebSocketClientCloseFrame = Readonly<{
type: "CLOSE";
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
category: WebSocketCloseCategory;
}>;
export type WebSocketClientFrame =
| WebSocketSubscribeFrame
| WebSocketUnsubscribeFrame
| WebSocketHeartbeatFrame
| WebSocketClientCloseFrame;
export type WebSocketProtocolFailure = Readonly<{
code:
| "BINARY_FRAME"
| "FRAME_TOO_LARGE"
| "MALFORMED_FRAME"
| "PROTOCOL_MISMATCH"
| "UNKNOWN_FRAME";
}>;
export type WebSocketProtocolResult<Value> =
| Readonly<{ ok: true; value: Value; byteLength: number }>
| Readonly<{ ok: false; error: WebSocketProtocolFailure }>;
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
const OPAQUE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/u;
const UNSIGNED_DECIMAL = /^(?:0|[1-9][0-9]{0,19})$/u;
const UINT64_MAX = 18_446_744_073_709_551_615n;
const MAX_FRAME_STRUCTURE_DEPTH = 32;
const MAX_FRAME_STRUCTURE_NODES = 4_096;
const CLOSE_CATEGORIES: readonly WebSocketCloseCategory[] = [
"NORMAL",
"RESTART",
"OVERLOADED",
"AUTH_REQUIRED",
"FORBIDDEN",
"PROTOCOL_MISMATCH",
"CURSOR_RESET",
"NETWORK_LOST",
];
const RESET_REASONS: readonly WebSocketResetReason[] = [
"CURSOR_EXPIRED",
"SEQUENCE_GAP",
"SERVER_RESET",
"SCOPE_CHANGED",
];
const LIMIT_KEYS = [
"maxBufferedAmountBytes",
"maxEventsPerSecond",
"maxFrameBytes",
"maxInboundQueueBytes",
"maxInboundQueueCount",
"maxOutboundQueueBytes",
"maxOutboundQueueCount",
"maxSubscriptions",
] as const;
const SERVER_KEYS = Object.freeze({
WELCOME: [
"connectionId",
"heartbeatAckTimeoutMs",
"heartbeatMs",
"limits",
"protocol",
"type",
],
SUBSCRIBED: [
"acceptedCursor",
"nextExpectedSequence",
"protocol",
"streamEpoch",
"subscriptionId",
"type",
],
UNSUBSCRIBED: ["protocol", "subscriptionId", "type"],
EVENT: ["envelope", "protocol", "subscriptionId", "type"],
RESET_REQUIRED: [
"protocol",
"reason",
"subscriptionId",
"type",
],
HEARTBEAT_ACK: ["nonce", "protocol", "type"],
CLOSE: ["category", "protocol", "type"],
} satisfies Record<string, readonly string[]>);
const CLIENT_KEYS = Object.freeze({
SUBSCRIBE: [
"cursor",
"protocol",
"scopeBinding",
"streamId",
"subscriptionId",
"type",
],
UNSUBSCRIBE: ["protocol", "subscriptionId", "type"],
HEARTBEAT: ["nonce", "protocol", "type"],
CLOSE: ["category", "protocol", "type"],
} satisfies Record<string, readonly string[]>);
export function decodeWebSocketServerFrame(
input: unknown,
maxFrameBytes: number,
): WebSocketProtocolResult<WebSocketServerFrame> {
if (typeof input !== "string") {
return protocolFailure("BINARY_FRAME");
}
if (!isPositiveInteger(maxFrameBytes)) {
return protocolFailure("FRAME_TOO_LARGE");
}
const byteLength = utf8ByteLength(input);
if (byteLength > maxFrameBytes) {
return protocolFailure("FRAME_TOO_LARGE");
}
if (
hasDuplicateJsonMembers(input, {
maxDepth: MAX_FRAME_STRUCTURE_DEPTH,
maxMembers: MAX_FRAME_STRUCTURE_NODES,
})
) {
return protocolFailure("MALFORMED_FRAME");
}
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
return protocolFailure("MALFORMED_FRAME");
}
if (!isRecord(parsed) || typeof parsed.type !== "string") {
return protocolFailure("MALFORMED_FRAME");
}
if (parsed.protocol !== REALTIME_WEBSOCKET_PROTOCOL) {
return protocolFailure("PROTOCOL_MISMATCH");
}
const frame = decodeKnownServerFrame(parsed);
if (!frame) {
return protocolFailure(
Object.hasOwn(SERVER_KEYS, parsed.type)
? "MALFORMED_FRAME"
: "UNKNOWN_FRAME",
);
}
try {
if (!freezeBoundedJsonTree(frame)) {
return protocolFailure("MALFORMED_FRAME");
}
return Object.freeze({
ok: true,
value: frame,
byteLength,
});
} catch {
return protocolFailure("MALFORMED_FRAME");
}
}
export function encodeWebSocketClientFrame(
frame: WebSocketClientFrame,
maxFrameBytes: number,
): WebSocketProtocolResult<string> {
if (
!isPositiveInteger(maxFrameBytes) ||
!isRecord(frame) ||
frame.protocol !== REALTIME_WEBSOCKET_PROTOCOL ||
typeof frame.type !== "string"
) {
return protocolFailure("MALFORMED_FRAME");
}
const keys = CLIENT_KEYS[frame.type as keyof typeof CLIENT_KEYS];
if (!keys || !hasExactKeys(frame, keys) || !isValidClientFrame(frame)) {
return protocolFailure(
keys ? "MALFORMED_FRAME" : "UNKNOWN_FRAME",
);
}
let value: string;
try {
value = JSON.stringify(frame);
} catch {
return protocolFailure("MALFORMED_FRAME");
}
const byteLength = utf8ByteLength(value);
if (byteLength > maxFrameBytes) {
return protocolFailure("FRAME_TOO_LARGE");
}
return Object.freeze({ ok: true, value, byteLength });
}
export function nextUnsignedSequence(
sequence: string,
): string | null {
if (!isUnsignedSequence(sequence)) return null;
const value = BigInt(sequence);
return value === UINT64_MAX ? null : String(value + 1n);
}
function decodeKnownServerFrame(
frame: Record<string, unknown>,
): WebSocketServerFrame | null {
switch (frame.type) {
case "WELCOME":
if (
!hasExactKeys(frame, SERVER_KEYS.WELCOME) ||
!isIdentifier(frame.connectionId) ||
!isPositiveInteger(frame.heartbeatMs) ||
!isPositiveInteger(frame.heartbeatAckTimeoutMs) ||
!isAdvertisedLimits(frame.limits)
) {
return null;
}
return frame as WebSocketWelcomeFrame;
case "SUBSCRIBED":
if (
!hasExactKeys(frame, SERVER_KEYS.SUBSCRIBED) ||
!isIdentifier(frame.subscriptionId) ||
!isIdentifier(frame.streamEpoch) ||
!isOptionalOpaque(frame.acceptedCursor) ||
!isUnsignedSequence(frame.nextExpectedSequence)
) {
return null;
}
return frame as WebSocketSubscribedFrame;
case "UNSUBSCRIBED":
if (
!hasExactKeys(frame, SERVER_KEYS.UNSUBSCRIBED) ||
!isIdentifier(frame.subscriptionId)
) {
return null;
}
return frame as WebSocketUnsubscribedFrame;
case "EVENT":
if (
!hasExactKeys(frame, SERVER_KEYS.EVENT) ||
!isIdentifier(frame.subscriptionId) ||
!isRecord(frame.envelope)
) {
return null;
}
return frame as WebSocketEventFrame;
case "RESET_REQUIRED":
if (
!hasExactKeys(frame, SERVER_KEYS.RESET_REQUIRED) ||
!isIdentifier(frame.subscriptionId) ||
!RESET_REASONS.includes(frame.reason as WebSocketResetReason)
) {
return null;
}
return frame as WebSocketResetRequiredFrame;
case "HEARTBEAT_ACK":
if (
!hasExactKeys(frame, SERVER_KEYS.HEARTBEAT_ACK) ||
!isIdentifier(frame.nonce)
) {
return null;
}
return frame as WebSocketHeartbeatAckFrame;
case "CLOSE":
if (
!hasExactKeys(frame, SERVER_KEYS.CLOSE) ||
!CLOSE_CATEGORIES.includes(
frame.category as WebSocketCloseCategory,
)
) {
return null;
}
return frame as WebSocketServerCloseFrame;
default:
return null;
}
}
function isValidClientFrame(
frame: Record<string, unknown>,
): boolean {
switch (frame.type) {
case "SUBSCRIBE":
return (
isIdentifier(frame.subscriptionId) &&
isIdentifier(frame.streamId) &&
isOptionalOpaque(frame.cursor) &&
isOpaque(frame.scopeBinding)
);
case "UNSUBSCRIBE":
return isIdentifier(frame.subscriptionId);
case "HEARTBEAT":
return isIdentifier(frame.nonce);
case "CLOSE":
return CLOSE_CATEGORIES.includes(
frame.category as WebSocketCloseCategory,
);
default:
return false;
}
}
function isAdvertisedLimits(
input: unknown,
): input is WebSocketAdvertisedLimits {
if (!isRecord(input) || !hasExactKeys(input, LIMIT_KEYS)) {
return false;
}
return LIMIT_KEYS.every((key) => isPositiveInteger(input[key]));
}
function isRecord(
input: unknown,
): input is Record<string, unknown> {
return (
typeof input === "object" &&
input !== null &&
!Array.isArray(input) &&
Object.getPrototypeOf(input) === Object.prototype
);
}
function hasExactKeys(
input: Record<string, unknown>,
expected: readonly string[],
): boolean {
const keys = Object.keys(input).sort();
return (
keys.length === expected.length &&
keys.every((key, index) => key === expected[index])
);
}
function isIdentifier(input: unknown): input is string {
return typeof input === "string" && IDENTIFIER.test(input);
}
function isOpaque(input: unknown): input is string {
return typeof input === "string" && OPAQUE_VALUE.test(input);
}
function isOptionalOpaque(input: unknown): input is string | null {
return input === null || isOpaque(input);
}
function isPositiveInteger(input: unknown): input is number {
return Number.isSafeInteger(input) && Number(input) > 0;
}
function isUnsignedSequence(input: unknown): input is string {
if (typeof input !== "string" || !UNSIGNED_DECIMAL.test(input)) {
return false;
}
try {
return BigInt(input) <= UINT64_MAX;
} catch {
return false;
}
}
function utf8ByteLength(input: string): number {
return new TextEncoder().encode(input).byteLength;
}
function protocolFailure(
code: WebSocketProtocolFailure["code"],
): WebSocketProtocolResult<never> {
return Object.freeze({
ok: false,
error: Object.freeze({ code }),
});
}
function freezeBoundedJsonTree(root: object): boolean {
const pending: Array<
Readonly<{
value: object;
depth: number;
freeze: boolean;
}>
> = [{ value: root, depth: 0, freeze: false }];
let discoveredNodes = 1;
while (pending.length > 0) {
const current = pending.pop();
if (!current) return false;
if (current.freeze) {
Object.freeze(current.value);
continue;
}
if (current.depth > MAX_FRAME_STRUCTURE_DEPTH) {
return false;
}
pending.push({ ...current, freeze: true });
for (const child of Object.values(current.value)) {
if (child !== null && typeof child === "object") {
discoveredNodes += 1;
if (discoveredNodes > MAX_FRAME_STRUCTURE_NODES) {
return false;
}
pending.push({
value: child,
depth: current.depth + 1,
freeze: false,
});
}
}
}
return true;
}
@@ -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 });
}
@@ -0,0 +1,391 @@
import { createFailure } from "../../contracts/errors.ts";
import {
getStorageDefinition,
isStorageValueAllowed,
type StorageDefinition,
} from "../../contracts/storage-keys.ts";
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import type {
StorageMutationResult,
StoragePort,
} from "../../application/ports/storage-port.ts";
import {
assertValidBrowserStorageByteLimit,
decodeBrowserStorageEnvelope,
DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES,
encodeBrowserStorageEnvelope,
type BrowserStorageCodecFailure,
} from "./browser-storage-codec.ts";
export type BrowserStorageDependencies = Readonly<{
localStorage?: Storage;
sessionStorage?: Storage;
now?: () => number;
diagnostics?: DiagnosticsPort;
maxSerializedBytes?: number;
resolveDefinition?: (logicalName: string) => StorageDefinition;
}>;
type StorageFailureCause =
| "QUOTA_EXCEEDED"
| "SIZE_LIMIT_EXCEEDED"
| "UNAVAILABLE"
| "VALUE_REJECTED";
export function createBrowserStorageAdapter(
dependencies: BrowserStorageDependencies = {},
): StoragePort {
const memoryOverlay = new Map<string, string>();
const suppressedPersistentValues = new Set<string>();
const now = dependencies.now ?? Date.now;
const maxSerializedBytes =
dependencies.maxSerializedBytes ??
DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES;
const resolveDefinition =
dependencies.resolveDefinition ?? getStorageDefinition;
assertValidBrowserStorageByteLimit(maxSerializedBytes);
function backendFor(name: string): Storage | undefined {
if (name === "localStorage") return dependencies.localStorage;
if (name === "sessionStorage") return dependencies.sessionStorage;
return undefined;
}
function definitionFor(
logicalName: string,
phase: string,
):
| Readonly<{ ok: true; value: StorageDefinition }>
| Extract<StorageMutationResult, { ok: false }> {
try {
return { ok: true, value: resolveDefinition(logicalName) };
} catch {
return unavailable(phase, logicalName, dependencies.diagnostics);
}
}
function currentTime(
phase: string,
logicalName: string,
):
| Readonly<{ ok: true; value: number }>
| Extract<StorageMutationResult, { ok: false }> {
try {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError("Invalid storage clock.");
}
return { ok: true, value };
} catch {
return unavailable(phase, logicalName, dependencies.diagnostics);
}
}
function discardRecord(
definition: StorageDefinition,
backend: Storage | undefined,
): void {
memoryOverlay.delete(definition.physicalKey);
suppressedPersistentValues.add(definition.physicalKey);
if (!backend) {
suppressedPersistentValues.delete(definition.physicalKey);
return;
}
try {
backend.removeItem(definition.physicalKey);
suppressedPersistentValues.delete(definition.physicalKey);
} catch {
// Keep the in-memory tombstone so the rejected value is not parsed again.
}
}
function readEnvelope(
raw: string,
definition: StorageDefinition,
backend: Storage | undefined,
logicalName: string,
) {
const decoded = decodeBrowserStorageEnvelope(raw, maxSerializedBytes);
if (!decoded.ok) {
recordStorageFailure(
dependencies.diagnostics,
"discard",
logicalName,
codecFailureCause(decoded.reason),
);
discardRecord(definition, backend);
return { ok: true as const, value: undefined };
}
const envelope = decoded.value;
const expectsExpiry = typeof definition.ttl === "number";
if (
envelope.schemaVersion !== definition.schemaVersion ||
expectsExpiry !== (envelope.expiresAt !== null) ||
!isStorageValueAllowed(definition, envelope.value)
) {
recordStorageFailure(
dependencies.diagnostics,
"discard",
logicalName,
"VALUE_REJECTED",
);
discardRecord(definition, backend);
return { ok: true as const, value: undefined };
}
if (envelope.expiresAt !== null) {
const timestamp = currentTime("read", logicalName);
if (!timestamp.ok) return timestamp;
if (envelope.expiresAt <= timestamp.value) {
discardRecord(definition, backend);
return { ok: true as const, value: undefined };
}
}
return { ok: true as const, value: envelope.value };
}
return Object.freeze({
read(logicalName) {
const selected = definitionFor(logicalName, "read");
if (!selected.ok) return selected;
const definition = selected.value;
const backend = backendFor(definition.backend);
const overlay = memoryOverlay.get(definition.physicalKey);
if (overlay !== undefined) {
return readEnvelope(
overlay,
definition,
backend,
logicalName,
);
}
if (suppressedPersistentValues.has(definition.physicalKey)) {
return { ok: true, value: undefined };
}
try {
const raw = backend?.getItem(definition.physicalKey);
if (raw === null || raw === undefined) {
return { ok: true, value: undefined };
}
return readEnvelope(raw, definition, backend, logicalName);
} catch {
return unavailable("read", logicalName, dependencies.diagnostics);
}
},
write(logicalName, value) {
const selected = definitionFor(logicalName, "write");
if (!selected.ok) return selected;
const definition = selected.value;
if (!isStorageValueAllowed(definition, value)) {
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
"VALUE_REJECTED",
);
return {
ok: false,
error: storageFailure(
"VALUE_REJECTED",
"write",
logicalName,
),
};
}
let expiresAt: number | null = null;
if (typeof definition.ttl === "number") {
const timestamp = currentTime("write", logicalName);
if (!timestamp.ok) return timestamp;
const expiration = timestamp.value + definition.ttl;
if (!Number.isSafeInteger(expiration)) {
return unavailable(
"write",
logicalName,
dependencies.diagnostics,
);
}
expiresAt = expiration;
}
const encoded = encodeBrowserStorageEnvelope(
{
schemaVersion: definition.schemaVersion,
expiresAt,
value,
},
maxSerializedBytes,
);
if (!encoded.ok) {
const cause = codecFailureCause(encoded.reason);
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
cause,
);
return {
ok: false,
error: storageFailure(cause, "write", logicalName),
};
}
if (definition.backend === "memory") {
memoryOverlay.set(definition.physicalKey, encoded.value);
suppressedPersistentValues.delete(definition.physicalKey);
return { ok: true };
}
const backend = backendFor(definition.backend);
try {
if (!backend) {
throw new DOMException("Storage unavailable", "SecurityError");
}
backend.setItem(definition.physicalKey, encoded.value);
memoryOverlay.delete(definition.physicalKey);
suppressedPersistentValues.delete(definition.physicalKey);
return { ok: true };
} catch (error) {
const cause: StorageFailureCause = isQuotaError(error)
? "QUOTA_EXCEEDED"
: "UNAVAILABLE";
if (definition.quotaFallback === "memory") {
memoryOverlay.set(definition.physicalKey, encoded.value);
suppressedPersistentValues.delete(definition.physicalKey);
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
cause,
);
return {
ok: false,
error: storageFailure(cause, "write", logicalName),
fallback: "memory",
};
}
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
cause,
);
return {
ok: false,
error: storageFailure(cause, "write", logicalName),
fallback: definition.quotaFallback,
};
}
},
remove(logicalName) {
const selected = definitionFor(logicalName, "remove");
if (!selected.ok) return selected;
const definition = selected.value;
const backend = backendFor(definition.backend);
memoryOverlay.delete(definition.physicalKey);
suppressedPersistentValues.add(definition.physicalKey);
try {
backend?.removeItem(definition.physicalKey);
suppressedPersistentValues.delete(definition.physicalKey);
return { ok: true };
} catch {
recordStorageFailure(
dependencies.diagnostics,
"remove",
logicalName,
"UNAVAILABLE",
);
return {
ok: false,
error: storageFailure("UNAVAILABLE", "remove", logicalName),
};
}
},
});
}
function codecFailureCause(
reason: BrowserStorageCodecFailure,
): StorageFailureCause {
return reason === "OVERSIZE"
? "SIZE_LIMIT_EXCEEDED"
: "VALUE_REJECTED";
}
function isQuotaError(error: unknown): boolean {
try {
if (!error || typeof error !== "object") return false;
const name = (error as Readonly<{ name?: unknown }>).name;
return (
typeof name === "string" &&
["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(name)
);
} catch {
return false;
}
}
function storageFailure(
cause: StorageFailureCause,
phase: string,
logicalName: string,
) {
const quota = cause === "QUOTA_EXCEEDED";
return createFailure(
quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE",
"STORAGE",
0,
{
code: `${safeLogicalName(logicalName)}_${phase.toUpperCase()}_${cause}`,
},
);
}
function unavailable(
phase: string,
logicalName: string,
diagnostics: DiagnosticsPort | undefined,
): Extract<StorageMutationResult, { ok: false }> {
recordStorageFailure(
diagnostics,
phase,
logicalName,
"UNAVAILABLE",
);
return {
ok: false,
error: storageFailure("UNAVAILABLE", phase, logicalName),
};
}
function recordStorageFailure(
diagnostics: DiagnosticsPort | undefined,
phase: string,
logicalName: string,
cause: StorageFailureCause,
): void {
try {
diagnostics?.record({
level: "warn",
eventId: "storage.operation.failed",
context: {
operation: `${phase}:${safeLogicalName(logicalName)}`,
error_kind:
cause === "QUOTA_EXCEEDED"
? "STORAGE_QUOTA_EXCEEDED"
: "STORAGE_UNAVAILABLE",
},
});
} catch {
// Storage behavior remains independent from diagnostics.
}
}
function safeLogicalName(logicalName: string): string {
return /^[A-Z][A-Z0-9_]{0,63}$/u.test(logicalName)
? logicalName
: "UNKNOWN_KEY";
}
@@ -0,0 +1,231 @@
export const DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES = 16_384;
const MAX_VALUE_DEPTH = 32;
const MAX_VALUE_NODES = 2_048;
const FORBIDDEN_RECORD_KEYS = new Set([
"__proto__",
"constructor",
"prototype",
]);
export type BrowserStorageEnvelope = Readonly<{
schemaVersion: number;
expiresAt: number | null;
value: unknown;
}>;
export type BrowserStorageCodecFailure =
| "INVALID_VALUE"
| "MALFORMED_RECORD"
| "OVERSIZE";
export type BrowserStorageCodecResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; reason: BrowserStorageCodecFailure }>;
/**
* Closed JSON codec for small Web Storage values. It rejects values that JSON
* would silently coerce or omit, accessors, exotic prototypes and unsafe
* record keys before they can cross the persistence boundary.
*/
export function encodeBrowserStorageEnvelope(
envelope: BrowserStorageEnvelope,
maxSerializedBytes: number,
): BrowserStorageCodecResult<string> {
try {
if (!validEnvelopeMetadata(envelope)) {
return { ok: false, reason: "INVALID_VALUE" };
}
const valueValidation = validateStorageValue(
envelope.value,
maxSerializedBytes,
);
if (!valueValidation.ok) return valueValidation;
const raw = JSON.stringify(envelope);
if (
typeof raw !== "string" ||
serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes
) {
return { ok: false, reason: "OVERSIZE" };
}
return { ok: true, value: raw };
} catch {
return { ok: false, reason: "INVALID_VALUE" };
}
}
export function decodeBrowserStorageEnvelope(
raw: string,
maxSerializedBytes: number,
): BrowserStorageCodecResult<BrowserStorageEnvelope> {
try {
if (serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes) {
return { ok: false, reason: "OVERSIZE" };
}
const parsed: unknown = JSON.parse(raw);
if (!isExactEnvelope(parsed)) {
return { ok: false, reason: "MALFORMED_RECORD" };
}
const valueValidation = validateStorageValue(
parsed.value,
maxSerializedBytes,
);
if (!valueValidation.ok) {
return {
ok: false,
reason:
valueValidation.reason === "OVERSIZE"
? "OVERSIZE"
: "MALFORMED_RECORD",
};
}
return { ok: true, value: parsed };
} catch {
return { ok: false, reason: "MALFORMED_RECORD" };
}
}
export function assertValidBrowserStorageByteLimit(value: number): void {
if (!Number.isSafeInteger(value) || value < 64) {
throw new TypeError(
"Browser storage serialized byte limit must be a safe integer of at least 64.",
);
}
}
function validEnvelopeMetadata(envelope: BrowserStorageEnvelope): boolean {
return (
Boolean(envelope) &&
typeof envelope === "object" &&
Number.isSafeInteger(envelope.schemaVersion) &&
envelope.schemaVersion > 0 &&
(envelope.expiresAt === null ||
(Number.isSafeInteger(envelope.expiresAt) && envelope.expiresAt >= 0))
);
}
function isExactEnvelope(value: unknown): value is BrowserStorageEnvelope {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const keys = Object.keys(value).sort();
if (
keys.length !== 3 ||
keys[0] !== "expiresAt" ||
keys[1] !== "schemaVersion" ||
keys[2] !== "value"
) {
return false;
}
return validEnvelopeMetadata(value as BrowserStorageEnvelope);
}
function serializedByteLength(raw: string, limit: number): number {
if (raw.length > limit) return limit + 1;
return new TextEncoder().encode(raw).byteLength;
}
function validateStorageValue(
root: unknown,
maxSerializedBytes: number,
): BrowserStorageCodecResult<void> {
let visited = 0;
const ancestors = new Set<object>();
function visit(
value: unknown,
depth: number,
): BrowserStorageCodecResult<void> {
visited += 1;
if (visited > MAX_VALUE_NODES || depth > MAX_VALUE_DEPTH) {
return { ok: false, reason: "OVERSIZE" };
}
if (
value === null ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value))
) {
return { ok: true, value: undefined };
}
if (typeof value === "string") {
if (value.length > maxSerializedBytes) {
return { ok: false, reason: "OVERSIZE" };
}
return { ok: true, value: undefined };
}
if (!value || typeof value !== "object") {
return { ok: false, reason: "INVALID_VALUE" };
}
if (ancestors.has(value)) {
return { ok: false, reason: "INVALID_VALUE" };
}
const prototype = Object.getPrototypeOf(value);
if (
!Array.isArray(value) &&
prototype !== Object.prototype &&
prototype !== null
) {
return { ok: false, reason: "INVALID_VALUE" };
}
if (Reflect.ownKeys(value).some((key) => typeof key === "symbol")) {
return { ok: false, reason: "INVALID_VALUE" };
}
const descriptors = Object.getOwnPropertyDescriptors(value);
const childValues: unknown[] = [];
if (Array.isArray(value)) {
if (
!Number.isSafeInteger(value.length) ||
value.length > MAX_VALUE_NODES
) {
return { ok: false, reason: "OVERSIZE" };
}
const descriptorKeys = Object.keys(descriptors).filter(
(key) => key !== "length",
);
if (descriptorKeys.length !== value.length) {
return { ok: false, reason: "INVALID_VALUE" };
}
for (let index = 0; index < value.length; index += 1) {
const descriptor = descriptors[String(index)];
if (
!descriptor ||
!descriptor.enumerable ||
!("value" in descriptor)
) {
return { ok: false, reason: "INVALID_VALUE" };
}
childValues.push(descriptor.value);
}
} else {
for (const [key, descriptor] of Object.entries(descriptors)) {
if (
key.length > maxSerializedBytes ||
FORBIDDEN_RECORD_KEYS.has(key) ||
!descriptor.enumerable ||
!("value" in descriptor)
) {
return { ok: false, reason: "INVALID_VALUE" };
}
childValues.push(descriptor.value);
}
}
ancestors.add(value);
try {
for (const child of childValues) {
const result = visit(child, depth + 1);
if (!result.ok) return result;
}
} finally {
ancestors.delete(value);
}
return { ok: true, value: undefined };
}
try {
return visit(root, 0);
} catch {
return { ok: false, reason: "INVALID_VALUE" };
}
}
+25
View File
@@ -0,0 +1,25 @@
export { createIndexedDbMaintenance } from "./indexeddb-maintenance.ts";
export { createIndexedDbRuntime } from "./indexeddb-runtime.ts";
export {
assertValidIndexedDbDatasetGovernance,
indexedDbPhysicalDatabaseName,
} from "./indexeddb-governance.ts";
export type {
IndexedDbCodec,
IndexedDbCodecResult,
IndexedDbCountBucket,
IndexedDbDataMigrationPolicy,
IndexedDbDataMigrationSource,
IndexedDbDurabilityPolicy,
IndexedDbIndexDefinition,
IndexedDbKeyRangePlan,
IndexedDbMaintenanceDependencies,
IndexedDbObservation,
IndexedDbQueryPlan,
IndexedDbQueryPolicy,
IndexedDbRuntimeDependencies,
IndexedDbScheduler,
IndexedDbSchemaMigration,
IndexedDbSchemaOperation,
} from "./indexeddb-types.ts";
@@ -0,0 +1,72 @@
import type {
BrowserDataOperation,
BrowserDataResult,
} from "../../../application/ports/browser-file-storage/shared.ts";
import { browserDataFailure } from "../../browser-file-storage/result.ts";
function exceptionName(error: unknown): string {
if (
error &&
typeof error === "object" &&
"name" in error &&
typeof error.name === "string"
) {
return error.name;
}
return "";
}
/**
* Maps the closed DOMException vocabulary without exposing an exception
* object, message, key or stored value across the adapter boundary.
*/
export function mapIndexedDbException(
error: unknown,
operation: BrowserDataOperation,
): BrowserDataResult<never> {
switch (exceptionName(error)) {
case "AbortError":
return browserDataFailure("ABORTED", operation);
case "ConstraintError":
return browserDataFailure("CONFLICT", operation);
case "DataCloneError":
case "DataError":
return browserDataFailure("CORRUPT_DATA", operation, {
recovery: "READ_ONLY",
});
case "InvalidAccessError":
case "InvalidStateError":
case "NotFoundError":
case "ReadOnlyError":
case "TransactionInactiveError":
case "VersionError":
return browserDataFailure("MIGRATION_FAILED", operation, {
recovery: "READ_ONLY",
});
case "NotAllowedError":
case "SecurityError":
return browserDataFailure("PERMISSION_DENIED", operation, {
recovery: "ONLINE_ONLY",
});
case "NotReadableError":
return browserDataFailure("NOT_READABLE", operation, {
retryable: true,
recovery: "REOPEN",
});
case "QuotaExceededError":
case "NS_ERROR_DOM_QUOTA_REACHED":
return browserDataFailure("QUOTA_EXCEEDED", operation, {
recovery: "READ_ONLY",
});
case "UnknownError":
return browserDataFailure("UNAVAILABLE", operation, {
retryable: true,
recovery: "REOPEN",
});
default:
return browserDataFailure("UNAVAILABLE", operation, {
retryable: true,
recovery: "RETRY",
});
}
}
@@ -0,0 +1,339 @@
import type { IndexedDbDatasetScope } from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
import {
assertValidStoragePolicy,
type BrowserStoragePolicy,
} from "../../../application/ports/browser-file-storage/shared.ts";
export const INDEXEDDB_DATASET_BINDING_KEY = "dataset-binding";
export const INDEXEDDB_DATASET_BUDGET_KEY = "dataset-budget";
const OPAQUE_SCOPE_TOKEN = /^[A-Za-z0-9_-]{16,48}$/u;
type StoredDatasetBinding = Readonly<{
bindingKey: typeof INDEXEDDB_DATASET_BINDING_KEY;
bindingVersion: 1;
scope: IndexedDbDatasetScope;
storagePolicy: BrowserStoragePolicy;
}>;
export type IndexedDbBindingVerification =
| Readonly<{ ok: true }>
| Readonly<{
ok: false;
reason: "ABORTED" | "CORRUPT" | "MISMATCH" | "MISSING" | "NATIVE_ERROR";
error?: unknown;
}>;
function validOpaqueToken(value: unknown): value is string {
return typeof value === "string" && OPAQUE_SCOPE_TOKEN.test(value);
}
export function assertValidIndexedDbDatasetGovernance(
scope: IndexedDbDatasetScope,
storagePolicy: BrowserStoragePolicy,
): void {
assertValidStoragePolicy(storagePolicy);
if (
!scope ||
typeof scope !== "object" ||
!validOpaqueToken(scope.authorityToken) ||
!validOpaqueToken(scope.namespaceToken) ||
!validOpaqueToken(scope.partitionToken) ||
new Set([
scope.authorityToken,
scope.namespaceToken,
scope.partitionToken,
]).size !== 3 ||
scope.accountScope !== storagePolicy.accountScope ||
scope.authorityToken === storagePolicy.owner ||
scope.namespaceToken === storagePolicy.namespace ||
scope.partitionToken === storagePolicy.namespace ||
(storagePolicy.classification === "PERSONAL" &&
scope.accountScope !== "OPAQUE_PARTITION") ||
(storagePolicy.classification === "CONFIDENTIAL" &&
scope.accountScope !== "OPAQUE_PARTITION")
) {
throw new TypeError("IndexedDB dataset governance is invalid.");
}
}
/**
* Physical identity is derived exclusively from opaque registry tokens. The
* readable policy namespace and all business/account identifiers are excluded.
*/
export function indexedDbPhysicalDatabaseName(
scope: IndexedDbDatasetScope,
): string {
if (
!scope ||
typeof scope !== "object" ||
!validOpaqueToken(scope.authorityToken) ||
!validOpaqueToken(scope.namespaceToken) ||
!validOpaqueToken(scope.partitionToken)
) {
throw new TypeError("IndexedDB dataset scope is invalid.");
}
return `ca-idb-v1:${scope.authorityToken}.${scope.namespaceToken}.${scope.partitionToken}`;
}
export function createIndexedDbDatasetBinding(
scope: IndexedDbDatasetScope,
storagePolicy: BrowserStoragePolicy,
): StoredDatasetBinding {
assertValidIndexedDbDatasetGovernance(scope, storagePolicy);
return Object.freeze({
bindingKey: INDEXEDDB_DATASET_BINDING_KEY,
bindingVersion: 1,
scope: Object.freeze({ ...scope }),
storagePolicy: Object.freeze({
...storagePolicy,
retention: Object.freeze({ ...storagePolicy.retention }),
}),
});
}
function isStoredDatasetBinding(
value: unknown,
): value is StoredDatasetBinding {
if (!value || typeof value !== "object") return false;
const binding = value as Partial<StoredDatasetBinding>;
if (
binding.bindingKey !== INDEXEDDB_DATASET_BINDING_KEY ||
binding.bindingVersion !== 1 ||
!binding.scope ||
!binding.storagePolicy
) {
return false;
}
try {
assertValidIndexedDbDatasetGovernance(
binding.scope,
binding.storagePolicy,
);
return true;
} catch {
return false;
}
}
function canonicalPolicy(policy: BrowserStoragePolicy): string {
return JSON.stringify([
policy.owner,
policy.namespace,
policy.classification,
policy.authority,
policy.accountScope,
policy.retention.kind,
policy.retention.kind === "TTL"
? policy.retention.maxAgeMs
: null,
policy.softBudgetBytes,
policy.hardBudgetBytes,
policy.evictionPriority,
policy.logoutAction,
policy.accountDeletionAction,
policy.pressureAction,
policy.unavailableFallback,
]);
}
export function sameIndexedDbDatasetBinding(
value: unknown,
expected: StoredDatasetBinding,
): boolean {
if (!isStoredDatasetBinding(value)) return false;
return (
value.scope.authorityToken === expected.scope.authorityToken &&
value.scope.namespaceToken === expected.scope.namespaceToken &&
value.scope.partitionToken === expected.scope.partitionToken &&
value.scope.accountScope === expected.scope.accountScope &&
canonicalPolicy(value.storagePolicy) ===
canonicalPolicy(expected.storagePolicy)
);
}
/**
* Queues binding validation inside the versionchange transaction. Any mismatch
* aborts that transaction, so schema changes cannot commit under the wrong
* namespace or policy.
*/
export function queueIndexedDbUpgradeBinding(
transaction: IDBTransaction,
governanceStore: string,
expected: StoredDatasetBinding,
oldVersion: number,
onRejected: () => void,
): void {
const store = transaction.objectStore(governanceStore);
if (oldVersion === 0) {
let addRequest: IDBRequest<IDBValidKey>;
try {
addRequest = store.add(expected);
} catch {
onRejected();
transaction.abort();
return;
}
addRequest.onerror = () => onRejected();
let budgetRequest: IDBRequest<IDBValidKey>;
try {
budgetRequest = store.add(
Object.freeze({
bindingKey: INDEXEDDB_DATASET_BUDGET_KEY,
budgetVersion: 1,
usedBytes: 0,
receiptCount: 0,
}),
);
} catch {
onRejected();
transaction.abort();
return;
}
budgetRequest.onerror = () => onRejected();
return;
}
const request = store.get(INDEXEDDB_DATASET_BINDING_KEY);
request.onerror = () => {
onRejected();
try {
transaction.abort();
} catch {
// The native request/transaction error owns the terminal state.
}
};
request.onsuccess = () => {
if (!sameIndexedDbDatasetBinding(request.result, expected)) {
onRejected();
try {
transaction.abort();
} catch {
// The mismatch remains fail-closed even if abort already won.
}
}
};
}
/**
* Post-open verification protects non-upgrade opens and maintenance callers.
*/
export function verifyIndexedDbDatasetBinding(
database: IDBDatabase,
governanceStore: string,
expected: StoredDatasetBinding,
signal?: AbortSignal,
): Promise<IndexedDbBindingVerification> {
if (signal?.aborted) {
return Promise.resolve({ ok: false, reason: "ABORTED" });
}
let transaction: IDBTransaction;
try {
transaction = database.transaction(governanceStore, "readonly");
} catch (error) {
return Promise.resolve({
ok: false,
reason: "NATIVE_ERROR",
error,
});
}
return new Promise((resolve) => {
let settled = false;
let observed: unknown;
let observedBudget: unknown;
let requestError: unknown;
let callerAborted = false;
const finish = (result: IndexedDbBindingVerification) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", onAbort);
resolve(result);
};
function onAbort(): void {
callerAborted = true;
try {
transaction.abort();
} catch {
// Completion determines the race.
}
}
signal?.addEventListener("abort", onAbort, { once: true });
transaction.onerror = () => {
requestError ??= transaction.error;
};
transaction.onabort = () =>
finish(
callerAborted
? { ok: false, reason: "ABORTED" }
: {
ok: false,
reason: "NATIVE_ERROR",
error: requestError ?? transaction.error,
},
);
transaction.oncomplete = () => {
if (observed === undefined) {
finish({ ok: false, reason: "MISSING" });
} else if (!isStoredDatasetBinding(observed)) {
finish({ ok: false, reason: "CORRUPT" });
} else if (!sameIndexedDbDatasetBinding(observed, expected)) {
finish({ ok: false, reason: "MISMATCH" });
} else if (
!observedBudget ||
typeof observedBudget !== "object" ||
(observedBudget as { bindingKey?: unknown }).bindingKey !==
INDEXEDDB_DATASET_BUDGET_KEY ||
(observedBudget as { budgetVersion?: unknown }).budgetVersion !==
1 ||
!Number.isSafeInteger(
(observedBudget as { usedBytes?: unknown }).usedBytes,
) ||
typeof (observedBudget as { usedBytes?: unknown }).usedBytes !==
"number" ||
(observedBudget as { usedBytes: number }).usedBytes < 0 ||
(observedBudget as { usedBytes: number }).usedBytes >
expected.storagePolicy.hardBudgetBytes
||
!Number.isSafeInteger(
(observedBudget as { receiptCount?: unknown }).receiptCount,
) ||
typeof (observedBudget as { receiptCount?: unknown })
.receiptCount !== "number" ||
(observedBudget as { receiptCount: number }).receiptCount < 0
) {
finish({ ok: false, reason: "CORRUPT" });
} else {
finish({ ok: true });
}
};
try {
const request = transaction
.objectStore(governanceStore)
.get(INDEXEDDB_DATASET_BINDING_KEY);
request.onerror = () => {
requestError ??= request.error;
};
request.onsuccess = () => {
observed = request.result;
};
const budgetRequest = transaction
.objectStore(governanceStore)
.get(INDEXEDDB_DATASET_BUDGET_KEY);
budgetRequest.onerror = () => {
requestError ??= budgetRequest.error;
};
budgetRequest.onsuccess = () => {
observedBudget = budgetRequest.result;
};
} catch (error) {
requestError = error;
try {
transaction.abort();
} catch {
finish({ ok: false, reason: "NATIVE_ERROR", error });
}
}
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
import type {
IndexedDbIndexDefinition,
IndexedDbSchemaMigration,
IndexedDbSchemaOperation,
} from "./indexeddb-types.ts";
const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/u;
function invalidMigration(): never {
throw new DOMException("Invalid IndexedDB schema migration.", "InvalidStateError");
}
function validIdentifier(value: string): boolean {
return SAFE_IDENTIFIER.test(value);
}
function validateIndex(index: IndexedDbIndexDefinition): void {
if (
!validIdentifier(index.name) ||
(typeof index.keyPath !== "string" &&
(!Array.isArray(index.keyPath) ||
index.keyPath.length === 0 ||
!index.keyPath.every(
(entry) => typeof entry === "string" && entry.length > 0,
))) ||
(typeof index.keyPath === "string" && index.keyPath.length === 0)
) {
invalidMigration();
}
}
function validateOperation(operation: IndexedDbSchemaOperation): void {
if (operation.kind === "CREATE_STORE") {
if (
!validIdentifier(operation.name) ||
operation.keyPath.length === 0 ||
operation.indexes?.some((index) => {
try {
validateIndex(index);
return false;
} catch {
return true;
}
})
) {
invalidMigration();
}
return;
}
if (
operation.kind !== "CREATE_INDEX" ||
!validIdentifier(operation.store)
) {
invalidMigration();
}
validateIndex(operation.index);
}
export function validateIndexedDbMigrations(
schemaVersion: number,
migrations: readonly IndexedDbSchemaMigration[],
): void {
if (
!Number.isSafeInteger(schemaVersion) ||
schemaVersion < 1 ||
migrations.length !== schemaVersion
) {
invalidMigration();
}
const ids = new Set<string>();
for (let index = 0; index < migrations.length; index += 1) {
const migration = migrations[index];
if (
!migration ||
!validIdentifier(migration.id) ||
ids.has(migration.id) ||
migration.fromVersion !== index ||
migration.toVersion !== index + 1
) {
invalidMigration();
}
ids.add(migration.id);
migration.operations.forEach(validateOperation);
}
}
function createIndex(
store: IDBObjectStore,
index: IndexedDbIndexDefinition,
): void {
if (store.indexNames.contains(index.name)) invalidMigration();
store.createIndex(
index.name,
Array.isArray(index.keyPath) ? [...index.keyPath] : index.keyPath,
{
unique: index.unique ?? false,
multiEntry: index.multiEntry ?? false,
},
);
}
function applyOperation(
db: IDBDatabase,
transaction: IDBTransaction,
operation: IndexedDbSchemaOperation,
): void {
switch (operation.kind) {
case "CREATE_STORE": {
if (db.objectStoreNames.contains(operation.name)) invalidMigration();
const store = db.createObjectStore(operation.name, {
keyPath: operation.keyPath,
autoIncrement: operation.autoIncrement ?? false,
});
for (const index of operation.indexes ?? []) createIndex(store, index);
return;
}
case "CREATE_INDEX": {
if (!db.objectStoreNames.contains(operation.store)) invalidMigration();
createIndex(transaction.objectStore(operation.store), operation.index);
return;
}
}
}
export function applyIndexedDbMigrations(
db: IDBDatabase,
transaction: IDBTransaction,
oldVersion: number,
newVersion: number,
migrations: readonly IndexedDbSchemaMigration[],
): number {
if (
!Number.isSafeInteger(oldVersion) ||
!Number.isSafeInteger(newVersion) ||
oldVersion < 0 ||
newVersion <= oldVersion ||
newVersion > migrations.length
) {
invalidMigration();
}
let applied = 0;
for (let version = oldVersion + 1; version <= newVersion; version += 1) {
const migration = migrations[version - 1];
if (
!migration ||
migration.fromVersion !== version - 1 ||
migration.toVersion !== version
) {
invalidMigration();
}
for (const operation of migration.operations) {
applyOperation(db, transaction, operation);
}
applied += 1;
}
return applied;
}
export function assertIndexedDbRuntimeStores(
db: IDBDatabase,
recordStore: string,
governanceStore: string,
retentionStore: string,
retentionEligibilityIndex: string,
lifecycleMetadataStores: readonly string[],
idempotencyStore: string,
idempotencyExpiryIndex: string,
): void {
if (
new Set([
recordStore,
governanceStore,
retentionStore,
idempotencyStore,
...lifecycleMetadataStores,
]).size !== 4 + lifecycleMetadataStores.length ||
!validIdentifier(recordStore) ||
!validIdentifier(governanceStore) ||
!validIdentifier(retentionStore) ||
!validIdentifier(retentionEligibilityIndex) ||
lifecycleMetadataStores.some(
(store) =>
!validIdentifier(store) ||
!db.objectStoreNames.contains(store),
) ||
!validIdentifier(idempotencyStore) ||
!validIdentifier(idempotencyExpiryIndex) ||
!db.objectStoreNames.contains(recordStore) ||
!db.objectStoreNames.contains(governanceStore) ||
!db.objectStoreNames.contains(retentionStore) ||
!db.objectStoreNames.contains(idempotencyStore)
) {
invalidMigration();
}
const transaction = db.transaction(
[retentionStore, idempotencyStore],
"readonly",
);
transaction
.objectStore(retentionStore)
.index(retentionEligibilityIndex);
transaction
.objectStore(idempotencyStore)
.index(idempotencyExpiryIndex);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
import type {
IndexedDbConnectionStatus,
IndexedDbCursor,
IndexedDbCursorKey,
IndexedDbDatasetScope,
IndexedDbLifecycleAuthorityDecision,
IndexedDbLifecycleAuthorityRequest,
} from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
import type {
BrowserDataFailureCode,
BrowserDataOperation,
BrowserStoragePolicy,
} from "../../../application/ports/browser-file-storage/shared.ts";
export type IndexedDbCodecResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false }>;
/**
* The codec is the only boundary allowed to turn an IndexedDB structured
* clone into a trusted value. It must accept every retained historical record
* version and emit only current-version wire values.
*/
export interface IndexedDbCodec<Value, WireValue> {
readonly currentVersion: number;
encode(value: Value): IndexedDbCodecResult<WireValue>;
/**
* Deterministic conservative byte estimate for the encoded wire value.
* Returning an invalid value or throwing rejects the write fail-closed.
*/
measureStoredBytes(value: WireValue): number;
decode(
codecVersion: number,
value: unknown,
): IndexedDbCodecResult<Value>;
/**
* Returns lowercase SHA-256 hex over a canonical, domain-approved wire
* representation. Raw labels, identifiers or reversible encodings are
* rejected by the runtime and must never be persisted as fingerprints. The
* canonicalization and digest contract must remain stable for at least the
* receipt retention plus supported rollback window.
*/
fingerprint(value: WireValue): string | Promise<string>;
}
export type IndexedDbIndexDefinition = Readonly<{
name: string;
keyPath: string | readonly string[];
unique?: boolean;
multiEntry?: boolean;
}>;
export type IndexedDbSchemaOperation =
| Readonly<{
kind: "CREATE_STORE";
name: string;
keyPath: string;
autoIncrement?: boolean;
indexes?: readonly IndexedDbIndexDefinition[];
}>
| Readonly<{
kind: "CREATE_INDEX";
store: string;
index: IndexedDbIndexDefinition;
}>;
export type IndexedDbSchemaMigration = Readonly<{
id: string;
fromVersion: number;
toVersion: number;
operations: readonly IndexedDbSchemaOperation[];
}>;
export type IndexedDbKeyRangePlan =
| Readonly<{ kind: "ONLY"; value: IndexedDbCursorKey }>
| Readonly<{
kind: "LOWER";
lower: IndexedDbCursorKey;
open?: boolean;
}>
| Readonly<{
kind: "UPPER";
upper: IndexedDbCursorKey;
open?: boolean;
}>
| Readonly<{
kind: "BOUND";
lower: IndexedDbCursorKey;
upper: IndexedDbCursorKey;
lowerOpen?: boolean;
upperOpen?: boolean;
}>;
export type IndexedDbQueryPlan = Readonly<{
index?: string;
range?: IndexedDbKeyRangePlan;
direction?: "next" | "prev";
limit: number;
}>;
export interface IndexedDbQueryPolicy<Query> {
plan(query: Query, cursor: IndexedDbCursor | null): IndexedDbQueryPlan;
}
export type IndexedDbDataMigrationSource = Readonly<{
key: string;
fromCodecVersion: number;
payload: unknown;
signal: AbortSignal | undefined;
}>;
/**
* Owns all domain-aware historical payload conversion. It runs outside an
* IndexedDB transaction, so asynchronous validation/crypto cannot accidentally
* make a transaction inactive.
*/
export interface IndexedDbDataMigrationPolicy<WireValue> {
readonly migrationId: string;
readonly targetCodecVersion: number;
measureStoredBytes(value: WireValue): number;
/**
* Must be backed by product rollout/session authority that keeps N-1
* old-codec writers drained for the entire migration and contract window.
* BroadcastChannel or a best-effort tab hint is not a correctness fence.
*/
isOldWriterDrainConfirmed(
signal: AbortSignal | undefined,
): boolean | Promise<boolean>;
migrate(
source: IndexedDbDataMigrationSource,
):
| IndexedDbCodecResult<WireValue>
| Promise<IndexedDbCodecResult<WireValue>>;
}
export type IndexedDbCountBucket =
| "0"
| "1"
| "2-10"
| "11-100"
| "101+";
/**
* Safe observation event. It intentionally contains no database/store/index
* name, key, account identifier, value or native exception.
*/
export type IndexedDbObservation = Readonly<{
operation: BrowserDataOperation;
outcome: "SUCCESS" | "FAILED" | "ABORTED" | "BLOCKED";
schemaVersion: number;
countBucket: IndexedDbCountBucket;
failureCode?: BrowserDataFailureCode;
}>;
export type IndexedDbScheduler = Readonly<{
setTimeout(callback: () => void, milliseconds: number): unknown;
clearTimeout(handle: unknown): void;
}>;
export type IndexedDbDurabilityPolicy = Readonly<{
read?: "default" | "strict" | "relaxed";
write?: "default" | "strict" | "relaxed";
}>;
export type IndexedDbRuntimeDependencies<Value, WireValue, Query> = Readonly<{
scope: IndexedDbDatasetScope;
storagePolicy: BrowserStoragePolicy;
/**
* Optional deployment assertion only. It cannot override the derived name
* and construction fails unless it is byte-for-byte equal.
*/
databaseNameAssertion?: string;
schemaVersion: number;
recordStore: string;
governanceStore: string;
retentionStore: string;
retentionEligibilityIndex: string;
/**
* Adapter-owned stores (for example migration checkpoints) whose metadata
* must be removed by partition/session lifecycle purge. Never include the
* immutable governance store.
*/
lifecycleMetadataStores: readonly string[];
idempotencyStore: string;
idempotencyExpiryIndex: string;
receiptRetentionMs: number;
maxIdempotencyReceipts: number;
migrations: readonly IndexedDbSchemaMigration[];
codec: IndexedDbCodec<Value, WireValue>;
queryPolicy: IndexedDbQueryPolicy<Query>;
factory?: IDBFactory;
keyRange?: Pick<
typeof IDBKeyRange,
"only" | "lowerBound" | "upperBound" | "bound"
>;
durability?: IndexedDbDurabilityPolicy;
blockedTimeoutMs?: number;
nowEpochMilliseconds?: () => number;
scheduler?: IndexedDbScheduler;
nowMonotonicMilliseconds?: () => number;
authorizeLifecycle(
request: IndexedDbLifecycleAuthorityRequest,
):
| IndexedDbLifecycleAuthorityDecision
| Promise<IndexedDbLifecycleAuthorityDecision>;
observe?: (event: IndexedDbObservation) => void;
onVersionChange?: (
status: Extract<IndexedDbConnectionStatus, { kind: "CLOSED" }>,
) => void;
}>;
export type IndexedDbMaintenanceDependencies<WireValue> = Readonly<{
scope: IndexedDbDatasetScope;
storagePolicy: BrowserStoragePolicy;
databaseNameAssertion?: string;
schemaVersion: number;
recordStore: string;
governanceStore: string;
retentionStore: string;
checkpointStore: string;
checkpointKey: string;
idempotencyStore: string;
idempotencyExpiryIndex: string;
migrationPolicy: IndexedDbDataMigrationPolicy<WireValue>;
factory?: IDBFactory;
keyRange?: Pick<
typeof IDBKeyRange,
"lowerBound" | "upperBound"
>;
durability?: IndexedDbDurabilityPolicy;
now?: () => number;
nowEpochMilliseconds?: () => number;
observe?: (event: IndexedDbObservation) => void;
}>;
@@ -0,0 +1,217 @@
import type {
DurableObjectDescriptor,
DurableObjectMaintenancePort,
DurableObjectStorePort,
OpenedDurableObject,
OpfsCapabilities,
OpfsPolicyMaintenanceReport,
OpfsReconciliationReport,
OpfsStorageScope,
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
import {
type BrowserDataFailureCode,
type BrowserDataResult,
type BrowserStoragePolicy,
} from "../../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import {
createIndexedDbOpfsJournal,
type IndexedDbOpfsJournal,
} from "./indexeddb-opfs-journal.ts";
import {
createOpfsByteStoreAdapter,
type OpfsMaintenanceAuthorityConsumer,
type OpfsMaintenanceAuthorityProvider,
} from "./opfs-byte-store-adapter.ts";
import {
resolveOpfsRuntimePolicy,
snapshotOpfsStoragePolicy,
snapshotOpfsStorageScope,
type OpfsRuntimePolicy,
type OpfsSafeObserver,
} from "./opfs-policy.ts";
import {
createOwnedOpfsWorkerClient,
type OwnedOpfsWorkerClient,
} from "./opfs-worker-client.ts";
export type BrowserOpfsRuntime = Readonly<{
objects: DurableObjectStorePort;
maintenance: DurableObjectMaintenancePort;
close(): void;
}>;
export type BrowserOpfsRuntimeDependencies = Readonly<{
workerUrl: string | URL;
workerName?: string;
/**
* Optional only for assertion/testing. When provided it must equal the
* deterministic name derived from scope.authorityToken.
*/
databaseName?: string;
scope: OpfsStorageScope;
storagePolicy: BrowserStoragePolicy;
policy: OpfsRuntimePolicy;
indexedDbFactory?: IDBFactory;
createTransactionId?: () => string;
createWorkerRequestId?: () => string;
createFencingToken?: () => string;
now?: () => number;
blockedTimeoutMs?: number;
observer?: OpfsSafeObserver;
requestMaintenanceAuthority?: OpfsMaintenanceAuthorityProvider;
consumeMaintenanceAuthority?: OpfsMaintenanceAuthorityConsumer;
}>;
/**
* Optional owned composition. Importing this module has no side effects and
* does not add OPFS to the default bootstrap or bundle. The caller must point
* workerUrl at an entry that starts startBrowserOpfsDedicatedWorker with the
* same resolved policy.
*/
export function createBrowserOpfsRuntime(
inputDependencies: BrowserOpfsRuntimeDependencies,
): BrowserOpfsRuntime {
const policy = resolveOpfsRuntimePolicy(inputDependencies.policy);
const scope = snapshotOpfsStorageScope(inputDependencies.scope);
const storagePolicy = snapshotOpfsStoragePolicy(
inputDependencies.storagePolicy,
);
if (
storagePolicy.namespace !== scope.namespace
) {
throw new TypeError("Browser OPFS scope binding is invalid.");
}
const dependencies: BrowserOpfsRuntimeDependencies = Object.freeze({
...inputDependencies,
scope,
storagePolicy,
policy,
});
const support = inspectBrowserOpfsSupport(policy);
if (!support.ok) {
return failedBrowserOpfsRuntime("UNSUPPORTED");
}
const journal: IndexedDbOpfsJournal = createIndexedDbOpfsJournal({
authorityToken: dependencies.scope.authorityToken,
databaseName: dependencies.databaseName,
factory: dependencies.indexedDbFactory,
createFencingToken: dependencies.createFencingToken,
blockedTimeoutMs: dependencies.blockedTimeoutMs,
});
let workerClient: OwnedOpfsWorkerClient;
try {
workerClient = createOwnedOpfsWorkerClient({
workerUrl: dependencies.workerUrl,
workerName: dependencies.workerName,
policy,
createRequestId: dependencies.createWorkerRequestId,
});
} catch {
journal.close();
return failedBrowserOpfsRuntime("UNAVAILABLE");
}
const byteStore = createOpfsByteStoreAdapter({
journal,
worker: workerClient.gateway,
scope: dependencies.scope,
storagePolicy: dependencies.storagePolicy,
policy,
createTransactionId: dependencies.createTransactionId,
now: dependencies.now,
observer: dependencies.observer,
requestMaintenanceAuthority:
dependencies.requestMaintenanceAuthority,
consumeMaintenanceAuthority:
dependencies.consumeMaintenanceAuthority,
});
let closed = false;
return Object.freeze({
...byteStore,
close() {
if (closed) return;
closed = true;
workerClient.terminate();
journal.close();
},
});
}
/**
* Side-effect-free platform probe. It is also the single preflight used by
* createBrowserOpfsRuntime, so unsupported engines return the same closed
* Result contract instead of throwing during Worker construction.
*/
export function inspectBrowserOpfsSupport(
policy: OpfsRuntimePolicy = resolveOpfsRuntimePolicy(),
): BrowserDataResult<OpfsCapabilities> {
const dedicatedWorkerAvailable = typeof Worker !== "undefined";
const opfsAvailable =
typeof navigator !== "undefined" &&
typeof navigator.storage?.getDirectory === "function";
const webLocksAvailable =
typeof navigator !== "undefined" &&
typeof navigator.locks?.request === "function";
const synchronousAccessHandleAvailable =
typeof FileSystemFileHandle !== "undefined" &&
"createSyncAccessHandle" in FileSystemFileHandle.prototype;
const capabilities: OpfsCapabilities = Object.freeze({
available:
dedicatedWorkerAvailable &&
opfsAvailable &&
webLocksAvailable &&
(synchronousAccessHandleAvailable ||
policy.allowAsyncWritableChunkFallback),
dedicatedWorkerRequired: true,
crossContextMutationLockAvailable: webLocksAvailable,
synchronousAccessHandleAvailable,
});
return capabilities.available
? browserDataSuccess(capabilities)
: browserDataFailure("UNSUPPORTED", "OBJECT_READ", {
recovery: "ONLINE_ONLY",
});
}
function failedBrowserOpfsRuntime(
code: Extract<
BrowserDataFailureCode,
"UNAVAILABLE" | "UNSUPPORTED"
>,
): BrowserOpfsRuntime {
const failure = <Value>(
operation:
| "OBJECT_READ"
| "OBJECT_WRITE"
| "OBJECT_DELETE"
| "OBJECT_RECONCILE",
): BrowserDataResult<Value> =>
browserDataFailure(code, operation, {
retryable: code === "UNAVAILABLE",
recovery: code === "UNAVAILABLE" ? "RETRY" : "ONLINE_ONLY",
});
return Object.freeze({
objects: Object.freeze({
capabilities: async () =>
failure<OpfsCapabilities>("OBJECT_READ"),
put: async () =>
failure<DurableObjectDescriptor>("OBJECT_WRITE"),
open: async () =>
failure<OpenedDurableObject>("OBJECT_READ"),
remove: async () => failure<void>("OBJECT_DELETE"),
}),
maintenance: Object.freeze({
reconcile: async () =>
failure<OpfsReconciliationReport>("OBJECT_RECONCILE"),
enforcePolicies: async () =>
failure<OpfsPolicyMaintenanceReport>("OBJECT_RECONCILE"),
}),
close() {},
});
}
+53
View File
@@ -0,0 +1,53 @@
export {
createBrowserOpfsRuntime,
inspectBrowserOpfsSupport,
type BrowserOpfsRuntime,
type BrowserOpfsRuntimeDependencies,
} from "./browser-opfs-runtime.ts";
export {
createOpfsByteStoreAdapter,
type OpfsByteStore,
type OpfsByteStoreDependencies,
type OpfsMaintenanceAuthorityConsumer,
type OpfsMaintenanceAuthorityDecision,
type OpfsMaintenanceAuthorityProvider,
type OpfsMaintenanceAuthorityRequest,
} from "./opfs-byte-store-adapter.ts";
export {
createIndexedDbOpfsJournal,
opfsJournalDatabaseName,
type IndexedDbOpfsJournal,
type IndexedDbOpfsJournalDependencies,
} from "./indexeddb-opfs-journal.ts";
export {
DEFAULT_OPFS_RUNTIME_POLICY,
resolveOpfsRuntimePolicy,
type OpfsRuntimePolicy,
type OpfsSafeObservation,
type OpfsSafeObserver,
} from "./opfs-policy.ts";
export {
createOpfsWorkerGateway,
createOwnedOpfsWorkerClient,
type OpfsWorkerClientDependencies,
type OpfsWorkerLike,
type OwnedOpfsWorkerClient,
} from "./opfs-worker-client.ts";
export {
createBrowserOpfsWorkerRuntime,
createOpfsWorkerRuntime,
createWebLockLeaseManager,
installOpfsWorkerMessageHandler,
startBrowserOpfsDedicatedWorker,
type BrowserOpfsWorkerDependencies,
type OpfsMutationLease,
type OpfsMutationLeaseManager,
type OpfsWorkerMessageHost,
type OpfsWorkerRuntime,
} from "./opfs-worker-runtime.ts";
export type {
OpfsWorkerGateway,
OpfsWorkerRequest,
OpfsWorkerRequestBody,
OpfsWorkerResponse,
} from "./opfs-worker-protocol.ts";

Some files were not shown because too many files have changed in this diff Show More