feat: 기능 추가 과정중

This commit is contained in:
donghyeon-ka
2026-07-30 15:58:20 +09:00
parent d3ef801fe6
commit 6c52cdb916
648 changed files with 126325 additions and 6680 deletions
@@ -1,124 +0,0 @@
/**
* Creates the skeleton-owned side of an external session integration.
* Credential acquisition and storage stay inside the supplied external owner.
*
* @param {{
* readState(): import("../../application/ports/auth-session-port.js").SessionState,
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* attachCredential(request: Request): Promise<Request>,
* recoverSession(): Promise<"restored" | "no-session">,
* notifyUnauthenticated(): void
* }} owner
* @returns {import("../../application/ports/auth-session-port.js").AuthSessionPort}
*/
export function createExternalAuthSessionAdapter(owner) {
return Object.freeze({
getState() {
return owner.readState();
},
subscribe(listener) {
return owner.subscribe(listener);
},
async beginSignIn(returnTo) {
await owner.beginSignIn(returnTo);
},
async signOut() {
await owner.signOut();
},
/** @param {Request} request */
async attach(request) {
const attached = await owner.attachCredential(request);
if (!(attached instanceof Request)) {
throw new TypeError("Auth owner returned an invalid request");
}
return attached;
},
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();
},
});
}
export function createAnonymousSessionAdapter() {
return createExternalAuthSessionAdapter({
readState: () => "unauthenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request,
recoverSession: async () => "no-session",
notifyUnauthenticated: () => {},
});
}
/**
* Local/test-only session seam. It never creates or stores credentials.
*
* @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState]
*/
export function createDemoSessionAdapter(initialState = "unauthenticated") {
let state = initialState;
const listeners = new Set();
function notify() {
for (const listener of listeners) listener();
}
/** @param {import("../../application/ports/auth-session-port.js").SessionState} next */
function setState(next) {
state = next;
notify();
}
return Object.freeze({
getState: () => state,
/** @param {() => void} listener */
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async beginSignIn() {
setState("authenticated");
},
async signOut() {
setState("unauthenticated");
},
/** @param {Request} request */
async attach(request) {
return request;
},
async recover() {
if (state === "recovery-pending") {
setState("authenticated");
return /** @type {const} */ ("restored");
}
return /** @type {const} */ ("no-session");
},
onUnauthenticated() {
setState("unauthenticated");
},
setState,
});
}
export function createUnavailableSessionAdapter() {
return Object.freeze({
getState: () => /** @type {const} */ ("integration-failed"),
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
/** @param {Request} request */
attach: async (request) => request,
recover: async () => /** @type {const} */ ("no-session"),
onUnauthenticated: () => {},
});
}
@@ -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,246 @@
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 {
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";
@@ -1,11 +1,11 @@
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.js";
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import {
projectDiagnosticRecord,
safeErrorKind,
type DiagnosticRecord,
type DiagnosticRecordInput,
} from "../../contracts/diagnostics.js";
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
} from "../../contracts/diagnostics.ts";
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
record() {},
+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" };
}
}
-589
View File
@@ -1,589 +0,0 @@
import { systemClock } from "../platform/system-clock.js";
import { getApiOperation } from "../../contracts/api-operations.js";
import {
createFailure as failure,
kindForStatus as statusKind,
normalizeUnknownFailure,
safeValidationIssues,
} from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js";
import {
validateEnvelope,
validateOperationPayload,
validateOperationRequest,
} from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js";
import {
attemptBucket,
durationBucket,
statusGroup,
} from "../../contracts/diagnostics.js";
const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
getState: () => /** @type {"unauthenticated"} */ ("unauthenticated"),
attach: async (request) => request,
recover: async () => /** @type {"no-session"} */ ("no-session"),
onUnauthenticated: () => {},
});
/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */
/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */
/**
* @typedef {{
* setTimeout(callback: () => void, milliseconds: number): unknown,
* clearTimeout(handle: unknown): void
* }} Scheduler
*/
/**
* @typedef {{ ok: true, value: unknown, meta: Record<string, string> } |
* { ok: false, error: HttpFailure }} HttpResult
*/
/**
* @param {{
* baseUrl: string,
* fetcher?: typeof fetch,
* authSession?: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* clock?: import("../../application/ports/clock-port.js").ClockPort,
* random?: () => number,
* validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* validateRequest?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string,
* timeoutMs?: number,
* maxRetryAttempts?: number,
* scheduler?: Scheduler,
* getOperation?: typeof getApiOperation,
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* correlationIdFactory?: () => string
* }} dependencies
*/
export function createHttpClient(dependencies) {
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 mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
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 ??
/** @type {Scheduler} */ ({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
/** @type {ReturnType<typeof setTimeout>} */ (handle),
),
});
/**
* @param {string | OperationRequestInput} request
* @param {{
* body?: unknown,
* routeId?: string,
* pathParams?: Record<string, string | number>,
* searchParams?: unknown,
* signal?: AbortSignal,
* idempotencyKey?: string,
* correlationId?: string
* }} [legacyInput]
* @returns {Promise<HttpResult>}
*/
async function execute(request, legacyInput = {}) {
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 operation = selectOperation(input.operationId);
const startedAt = clock.now();
const correlationId = input.correlationId ?? correlationIdFactory();
/**
* @param {HttpResult} outcome
* @param {"success" | "recovered" | "failed" | "aborted"} outcomeKind
*/
function finalize(outcome, outcomeKind) {
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),
attempt_count_bucket: attemptBucket(
error?.attemptCount ?? retryCount + 1,
),
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;
}
const logicalIdempotencyKey =
operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory()
: undefined;
let retryCount = 0;
let recoveryUsed = false;
while (true) {
const attempt = retryCount;
/** @type {HttpResult} */
const outcome = await performAttempt({
operation,
input,
attempt,
idempotencyKey: logicalIdempotencyKey,
});
if (outcome.ok) {
return finalize(
outcome,
retryCount > 0 || recoveryUsed ? "recovered" : "success",
);
}
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
recoveryUsed = true;
const recovered = await recoverSession(
authSession,
operation,
outcome.error,
);
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;
try {
await clock.sleep(delay, input.signal);
} catch {
return finalize(
{
ok: false,
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED",
}),
},
"aborted",
);
}
}
}
/**
* @param {{
* operation: ReturnType<typeof getApiOperation>,
* input: OperationRequestInput,
* attempt: number,
* idempotencyKey?: string
* }} context
* @returns {Promise<HttpResult>}
*/
async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context;
/** @type {unknown} */
let parsedSearch = {};
let parsedBody;
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;
}
}
const target = buildRequestTarget(
dependencies.baseUrl,
operation,
input.pathParams,
parsedSearch,
);
if (!target.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: target.code,
}),
};
}
const controller = new AbortController();
let timedOut = false;
const timeout = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs ?? defaultTimeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
if (input.signal?.aborted) onExternalAbort();
const headers = new Headers({ Accept: "application/json" });
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
let request = new Request(target.url, {
method: operation.method,
headers,
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
signal: controller.signal,
});
try {
if (operation.auth === "external-session") {
try {
request = await authSession.attach(request);
} catch {
return {
ok: false,
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
code: "AUTH_ATTACH_FAILED",
}),
};
}
}
const response = await fetcher(request);
return await parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
);
} 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 });
}
/**
* @param {Response} response
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
* @param {number} attempt
* @param {(schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }} validatePayload
* @param {(operationId: string, payload: unknown) => unknown} mapPayload
* @returns {Promise<HttpResult>}
*/
async function parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
) {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return {
ok: false,
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
code: "CONTENT_TYPE_MISMATCH",
httpStatus: response.status,
}),
};
}
let envelope;
try {
envelope = await response.json();
} catch {
return {
ok: false,
error: failure("MALFORMED_JSON", operation.operationId, attempt, {
code: "MALFORMED_JSON",
httpStatus: response.status,
}),
};
}
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 =
/** @type {Record<string, unknown>} */ (envelopeValidation.data);
if (response.ok && 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 {
return {
ok: true,
value: mapPayload(operation.operationId, payload.data),
meta: safeMeta(envelopeRecord.meta),
};
} catch (error) {
return {
ok: false,
error: normalizeUnknownFailure(error, {
operationId: operation.operationId,
attempt,
}),
};
}
}
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
: {};
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)
: undefined,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: undefined,
}),
};
}
/**
* @param {import("../../application/ports/auth-session-port.js").AuthSessionPort} authSession
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
* @param {HttpFailure} originalFailure
* @returns {Promise<{ok: true} | {ok: false, error: HttpFailure}>}
*/
async function recoverSession(authSession, operation, originalFailure) {
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, {
code: "AUTH_REQUIRED",
httpStatus: 401,
}),
};
}
} catch {
// Normalized below.
}
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
originalFailure.attemptCount,
{ code: "AUTH_RECOVERY_FAILED" },
),
};
}
/**
* @param {string} kind
* @param {string} operationId
* @param {number} attempt
* @param {FailureDetails} [details]
* @returns {HttpFailure}
*/
/** @param {unknown} envelope */
function safeBackendCode(envelope) {
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
const error = /** @type {Record<string, unknown>} */ (envelope).error;
if (!error || typeof error !== "object") return "HTTP_FAILURE";
const code = /** @type {Record<string, unknown>} */ (error).code;
return typeof code === "string" ? code : "HTTP_FAILURE";
}
/** @param {unknown} meta @returns {Record<string, string>} */
function safeMeta(meta) {
if (!meta || typeof meta !== "object") return {};
const metaRecord = /** @type {Record<string, unknown>} */ (meta);
return {
...(typeof metaRecord.requestId === "string"
? { requestId: metaRecord.requestId }
: {}),
...(typeof metaRecord.traceId === "string" ? { traceId: metaRecord.traceId } : {}),
};
}
/** @param {string} value */
function parseRetryAfterHeader(value) {
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 - Date.now()) : undefined;
}
+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",
)
);
}
+64 -4
View File
@@ -1,4 +1,4 @@
import type { ApiOperation } from "../../contracts/api-operations.js";
import type { ApiOperation } from "../../contracts/api-operations.ts";
export type OperationRequestInput = Readonly<{
operationId: string;
@@ -15,7 +15,12 @@ export type RequestTargetResult =
| Readonly<{ success: true; url: URL }>
| Readonly<{
success: false;
code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID";
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;
@@ -26,7 +31,35 @@ export function buildRequestTarget(
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) => {
@@ -36,12 +69,27 @@ export function buildRequestTarget(
missingPathParameter = true;
return "";
}
return encodeURIComponent(String(value));
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 ||
@@ -51,7 +99,12 @@ export function buildRequestTarget(
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
const url = new URL(pathname, baseUrl);
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),
@@ -70,5 +123,12 @@ export function buildRequestTarget(
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 };
}
-5
View File
@@ -1,5 +0,0 @@
/** @param {string} operationId @param {unknown} payload */
export function mapOperationPayload(operationId, payload) {
void payload;
throw new TypeError(`No boundary mapper registered for ${operationId}`);
}
+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");
}
@@ -1,27 +1,23 @@
const retryKinds = new Set([
const retryKinds: ReadonlySet<string> = new Set([
"NETWORK_UNREACHABLE",
"REQUEST_TIMEOUT",
"RATE_LIMITED",
"SERVER_FAILURE",
]);
/**
* @param {number} retryIndex
* @param {() => number} [random]
* @param {number} [baseDelayMs]
* @param {number} [maxDelayMs]
*/
export function calculateBackoff(
retryIndex,
retryIndex: number,
random = Math.random,
baseDelayMs = 250,
maxDelayMs = 2_000,
) {
): number {
return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random();
}
/** @param {string | null | undefined} value @param {number} [now] */
export function parseRetryAfter(value, now = Date.now()) {
export function parseRetryAfter(
value: string | null | undefined,
now = Date.now(),
): number | null {
if (!value) return null;
const seconds = Number(value);
@@ -34,13 +30,24 @@ export function parseRetryAfter(value, now = Date.now()) {
return Math.max(0, timestamp - now);
}
/**
* @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
* @param {number} retryCount
* @param {number} [maxRetries]
*/
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
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;
@@ -61,13 +68,12 @@ export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
return operation.idempotency === "safe" || operation.idempotency === "keyed";
}
/**
* @param {{ kind: string, retryAfterMs?: number, retryAfter?: string }} failure
* @param {number} retryIndex
* @param {() => number} [random]
* @param {number} [now]
*/
export function retryDelay(failure, retryIndex, random = Math.random, now = Date.now()) {
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;
-92
View File
@@ -1,92 +0,0 @@
import { z } from "zod";
const metaSchema = z
.object({
requestId: z.string().min(1),
traceId: z.string().min(1),
correlationId: z.string().min(1).optional(),
})
.passthrough();
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().min(1),
category: z.string().min(1).optional(),
message: z.string().optional(),
retryable: z.boolean().optional(),
details: z.unknown().optional(),
})
.strict(),
meta: metaSchema,
})
.strict();
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
successEnvelopeSchema,
failureEnvelopeSchema,
]);
const payloadSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
const requestSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
/** @param {unknown} value */
export function validateEnvelope(value) {
return projectResult(responseEnvelopeSchema.safeParse(value));
}
/** @param {string} schemaId @param {unknown} value */
export function validateOperationPayload(schemaId, value) {
const schema = payloadSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
/** @param {string} schemaId @param {unknown} value */
export function validateOperationRequest(schemaId, value) {
const schema = requestSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
/** @param {string} schemaId */
function missingSchema(schemaId) {
return {
success: /** @type {false} */ (false),
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
};
}
/**
* @param {{ success: true, data: unknown } |
* { success: false, error: { issues: Array<{ path: PropertyKey[], code: string }> } }} result
*/
function projectResult(result) {
if (result.success) {
return {
success: /** @type {true} */ (true),
data: structuredClone(result.data),
};
}
return {
success: /** @type {false} */ (false),
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
+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,
})),
};
}
@@ -1,5 +1,6 @@
/** @type {import("../../application/ports/clock-port.js").ClockPort} */
export const systemClock = Object.freeze({
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) => {
@@ -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,86 @@
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,
ServerStateScopeRuntime,
} from "../../contracts/server-state-scope.ts";
export function createServerStateScopeRuntime(dependencies: Readonly<{
session: Pick<AuthSessionPort, "subscribe">;
queryInvalidation: QueryInvalidationCoordinator;
tokenFactory?: () => string;
}>): ServerStateScopeRuntime {
const listeners = new Set<() => void>();
let generation = 1;
let identities = newIdentityRegistry(dependencies.tokenFactory);
let fingerprint = scopeFingerprint(dependencies.tokenFactory);
let disposed = false;
let resetChain = Promise.resolve();
function createSnapshot(): CacheScopeSnapshot {
const capturedGeneration = generation;
const capturedIdentities = identities;
return Object.freeze({
generation: capturedGeneration,
fingerprint,
identities: capturedIdentities,
isCurrent: () =>
!disposed &&
generation === capturedGeneration &&
identities === capturedIdentities,
});
}
let currentSnapshot = createSnapshot();
const unsubscribe = dependencies.session.subscribe(() => {
if (disposed) return;
const previousIdentities = identities;
const targetGeneration = ++generation;
resetChain = resetChain
.then(() => dependencies.queryInvalidation.resetLocal())
.catch(() => {})
.finally(() => {
previousIdentities.close();
if (disposed || generation !== targetGeneration) return;
identities = newIdentityRegistry(dependencies.tokenFactory);
fingerprint = scopeFingerprint(dependencies.tokenFactory);
currentSnapshot = createSnapshot();
for (const listener of listeners) listener();
});
});
return Object.freeze({
getSnapshot: () => currentSnapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
if (disposed) return;
disposed = true;
unsubscribe();
listeners.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,329 @@
import type { QueryClient } from "@tanstack/react-query";
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import type {
QueryInvalidationCoordinator,
QueryInvalidationTopic,
QueryMutationLease,
} from "../../contracts/query-invalidation.ts";
import { isCacheInvalidationTopic } from "../../contracts/cache-invalidation.ts";
import type {
BrowserCrossContextInvalidation,
CrossContextInvalidationDelivery,
} from "../cross-context-invalidation/index.ts";
export type InstalledQueryInvalidationDefinition = Readonly<{
namespace: readonly unknown[];
invalidationTopic: QueryInvalidationTopic;
crossContext: "invalidate-only";
version: number;
persistence: "disabled";
}>;
export type TanStackCacheCoordinatorDependencies = Readonly<{
queryClient: QueryClient;
queryRegistry: Readonly<
Record<string, InstalledQueryInvalidationDefinition>
>;
crossContext?: BrowserCrossContextInvalidation;
diagnostics?: DiagnosticsPort;
}>;
type RuntimeDefinition = Readonly<{
namespace: readonly unknown[];
topic: QueryInvalidationTopic;
version: number;
}>;
const MAX_NAMESPACE_PARTS = 8;
const MAX_NAMESPACE_BYTES = 1_024;
/**
* 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 {
const definitions = buildDefinitions(dependencies.queryRegistry);
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 definition(topic: QueryInvalidationTopic): RuntimeDefinition {
const selected = definitions.get(topic);
if (!selected) {
throw new TypeError("Unregistered query invalidation topic.");
}
return selected;
}
async function invalidateLocal(
topic: QueryInvalidationTopic,
expectedGeneration = lifecycleGeneration,
): Promise<void> {
if (
disposed ||
resetting ||
expectedGeneration !== lifecycleGeneration
) {
return;
}
const selected = definition(topic);
try {
await dependencies.queryClient.invalidateQueries({
queryKey: selected.namespace,
exact: false,
refetchType: "active",
});
} catch {
report("invalidate");
}
}
function receiveRemote(
delivery: CrossContextInvalidationDelivery,
): void {
if (disposed) return;
const selected = definitions.get(delivery.event.topic);
if (!selected) {
report("unknown-topic");
return;
}
if (delivery.ordering === "GAP") {
for (const candidate of definitions.values()) {
pendingRemote.add(candidate.topic);
}
report("sequence-gap");
} else {
pendingRemote.add(selected.topic);
}
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(topic, 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) definition(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);
for (const topic of selectedTopics) {
const selected = definition(topic);
await invalidateLocal(topic);
const published = dependencies.crossContext?.publish({
topic,
topicVersion: selected.version,
});
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");
}
try {
await dependencies.queryClient.cancelQueries();
} catch {
report("reset-cancel");
}
dependencies.queryClient.clear();
})().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 buildDefinitions(
registry: Readonly<
Record<string, InstalledQueryInvalidationDefinition>
>,
): ReadonlyMap<string, RuntimeDefinition> {
const definitions = new Map<string, RuntimeDefinition>();
for (const candidate of Object.values(registry)) {
if (
!candidate ||
!isCacheInvalidationTopic(candidate.invalidationTopic) ||
candidate.crossContext !== "invalidate-only" ||
candidate.persistence !== "disabled" ||
!Number.isSafeInteger(candidate.version) ||
candidate.version < 1 ||
!isSafeNamespace(candidate.namespace) ||
definitions.has(candidate.invalidationTopic)
) {
throw new TypeError("Query invalidation registry is invalid.");
}
definitions.set(
candidate.invalidationTopic,
Object.freeze({
namespace: Object.freeze(structuredClone(candidate.namespace)),
topic: candidate.invalidationTopic,
version: candidate.version,
}),
);
}
if (definitions.size === 0) {
throw new TypeError(
"Query invalidation registry requires at least one topic.",
);
}
return definitions;
}
function isSafeNamespace(value: unknown): value is readonly unknown[] {
if (
!Array.isArray(value) ||
value.length < 1 ||
value.length > MAX_NAMESPACE_PARTS ||
typeof value[0] !== "string"
) {
return false;
}
try {
const serialized = JSON.stringify(value);
return (
typeof serialized === "string" &&
new TextEncoder().encode(serialized).byteLength <=
MAX_NAMESPACE_BYTES
);
} catch {
return false;
}
}
@@ -1,7 +1,13 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { createFailure } from "../../contracts/errors.js";
import { safeErrorKind } from "../../contracts/diagnostics.js";
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,
@@ -12,12 +18,10 @@ export const QUERY_CACHE_DEFAULTS = Object.freeze({
persistence: false,
});
/**
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
*/
export function createQueryClient(dependencies = {}) {
/** @param {string} operation @param {unknown} error */
function report(operation, error) {
export function createQueryClient(
dependencies: QueryCacheDependencies = {},
): QueryClient {
function report(operation: string, error: unknown): void {
try {
dependencies.diagnostics?.record({
level: "warn",
@@ -52,12 +56,10 @@ export function createQueryClient(dependencies = {}) {
});
}
/**
* @param {QueryClient} queryClient
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
* @returns {import("../../application/ports/query-cache-port.js").QueryCachePort}
*/
export function createQueryCacheAdapter(queryClient, dependencies = {}) {
export function createQueryCacheAdapter(
queryClient: QueryClient,
dependencies: QueryCacheDependencies = {},
): QueryCachePort {
return Object.freeze({
read(key) {
try {
@@ -85,12 +87,11 @@ export function createQueryCacheAdapter(queryClient, dependencies = {}) {
});
}
/**
* @param {string} phase
* @param {readonly unknown[]} key
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
*/
function cacheFailure(phase, key, 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({
@@ -105,7 +106,7 @@ function cacheFailure(phase, key, diagnostics) {
// Cache behavior remains independent from diagnostics.
}
return {
ok: /** @type {false} */ (false),
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;
}
@@ -1,178 +0,0 @@
import { createFailure } from "../../contracts/errors.js";
import { getStorageDefinition } from "../../contracts/storage-keys.js";
/**
* @param {{
* localStorage?: Storage,
* sessionStorage?: Storage,
* now?: () => number,
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort
* }} [dependencies]
* @returns {import("../../application/ports/storage-port.js").StoragePort}
*/
export function createBrowserStorageAdapter(dependencies = {}) {
const memory = new Map();
const now = dependencies.now ?? Date.now;
/** @param {string} name */
function backendFor(name) {
if (name === "localStorage") return dependencies.localStorage;
if (name === "sessionStorage") return dependencies.sessionStorage;
return undefined;
}
return Object.freeze({
read(logicalName) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("read", logicalName, dependencies.diagnostics);
}
const backend = backendFor(definition.backend);
try {
const raw = backend?.getItem(definition.physicalKey);
if (raw === null || raw === undefined) {
return { ok: true, value: memory.get(definition.physicalKey) };
}
const envelope = JSON.parse(raw);
if (
!envelope ||
typeof envelope !== "object" ||
envelope.schemaVersion !== definition.schemaVersion
) {
backend?.removeItem(definition.physicalKey);
return { ok: true, value: undefined };
}
if (typeof envelope.expiresAt === "number" && envelope.expiresAt <= now()) {
backend?.removeItem(definition.physicalKey);
return { ok: true, value: undefined };
}
return { ok: true, value: structuredClone(envelope.value) };
} catch {
return unavailable("read", logicalName, dependencies.diagnostics);
}
},
write(logicalName, value) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("write", logicalName, dependencies.diagnostics);
}
const expiresAt =
typeof definition.ttl === "number" ? now() + definition.ttl : null;
const envelope = {
schemaVersion: definition.schemaVersion,
expiresAt,
value: structuredClone(value),
};
const backend = backendFor(definition.backend);
try {
if (!backend) throw new DOMException("Storage unavailable", "SecurityError");
backend.setItem(definition.physicalKey, JSON.stringify(envelope));
return { ok: true };
} catch (error) {
const quota =
error instanceof DOMException &&
["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(error.name);
if (definition.quotaFallback === "memory") {
memory.set(definition.physicalKey, structuredClone(value));
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
quota,
);
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: "memory",
};
}
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
quota,
);
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: definition.quotaFallback,
};
}
},
remove(logicalName) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("remove", logicalName, dependencies.diagnostics);
}
try {
backendFor(definition.backend)?.removeItem(definition.physicalKey);
memory.delete(definition.physicalKey);
return { ok: true };
} catch {
return unavailable("remove", logicalName, dependencies.diagnostics);
}
},
});
}
/** @param {boolean} quota @param {string} phase @param {string} logicalName */
function storageFailure(quota, phase, logicalName) {
return createFailure(
quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE",
"STORAGE",
0,
{
code: `${logicalName}_${phase.toUpperCase()}_${
quota ? "QUOTA_EXCEEDED" : "UNAVAILABLE"
}`,
},
);
}
/**
* @param {string} phase
* @param {string} logicalName
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
*/
function unavailable(phase, logicalName, diagnostics) {
recordStorageFailure(diagnostics, phase, logicalName, false);
return {
ok: /** @type {false} */ (false),
error: storageFailure(false, phase, logicalName),
};
}
/**
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
* @param {string} phase
* @param {string} logicalName
* @param {boolean} quota
*/
function recordStorageFailure(diagnostics, phase, logicalName, quota) {
try {
diagnostics?.record({
level: "warn",
eventId: "storage.operation.failed",
context: {
operation: `${phase}:${logicalName}`,
error_kind: quota
? "STORAGE_QUOTA_EXCEEDED"
: "STORAGE_UNAVAILABLE",
},
});
} catch {
// Storage behavior remains independent from diagnostics.
}
}
@@ -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";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
import {
assertValidStoragePolicy,
isValidByteLength,
type BrowserDataFailureCode,
type BrowserDataOperation,
type BrowserStoragePolicy,
} from "../../../application/ports/browser-file-storage/shared.ts";
import type { OpfsStorageScope } from "../../../application/ports/browser-file-storage/opfs-ports.ts";
export type OpfsRuntimePolicy = Readonly<{
rootDirectoryName: string;
mutationLockName: string;
chunkSizeBytes: number;
maxObjectBytes: number;
maxChunkCount: number;
rpcTimeoutMs: number;
reconciliationBudgetMs: number;
reconciliationBatchSize: number;
orphanGracePeriodMs: number;
orphanGcBatchSize: number;
maxCancellationTombstones: number;
allowAsyncWritableChunkFallback: boolean;
isObjectIdAllowed: (objectId: string) => boolean;
isMediaTypeAllowed: (mediaType: string) => boolean;
}>;
export type OpfsSafeObservation = Readonly<{
operation: BrowserDataOperation;
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
failureCode?: BrowserDataFailureCode;
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB";
transactionBucket?: "0" | "1_10" | "11_100" | "GT_100";
}>;
export type OpfsSafeObserver = (observation: OpfsSafeObservation) => void;
const SAFE_SEGMENT = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
const OPAQUE_OBJECT_ID = /^[A-Za-z0-9_-]{8,128}$/u;
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:\s*;.*)?$/iu;
export const DEFAULT_OPFS_RUNTIME_POLICY: OpfsRuntimePolicy = Object.freeze({
rootDirectoryName: "ca-frontend-opfs-v1",
mutationLockName: "ca-frontend-opfs-v1:mutation",
chunkSizeBytes: 4 * 1024 * 1024,
maxObjectBytes: 2 * 1024 * 1024 * 1024,
maxChunkCount: 512,
rpcTimeoutMs: 60_000,
reconciliationBudgetMs: 5_000,
reconciliationBatchSize: 100,
orphanGracePeriodMs: 24 * 60 * 60 * 1_000,
orphanGcBatchSize: 100,
maxCancellationTombstones: 1_024,
allowAsyncWritableChunkFallback: true,
isObjectIdAllowed: (objectId) => OPAQUE_OBJECT_ID.test(objectId),
isMediaTypeAllowed: (mediaType) => MEDIA_TYPE.test(mediaType),
});
export function resolveOpfsRuntimePolicy(
policy: Partial<OpfsRuntimePolicy> = {},
): OpfsRuntimePolicy {
const resolved: OpfsRuntimePolicy = Object.freeze({
...DEFAULT_OPFS_RUNTIME_POLICY,
...policy,
});
assertOpfsRuntimePolicy(resolved);
return resolved;
}
export function assertOpfsRuntimePolicy(policy: OpfsRuntimePolicy): void {
if (
!SAFE_SEGMENT.test(policy.rootDirectoryName) ||
policy.mutationLockName.length === 0 ||
!Number.isSafeInteger(policy.chunkSizeBytes) ||
policy.chunkSizeBytes < 64 * 1024 ||
policy.chunkSizeBytes > 64 * 1024 * 1024 ||
!isValidByteLength(policy.maxObjectBytes) ||
policy.maxObjectBytes < policy.chunkSizeBytes ||
!Number.isSafeInteger(policy.maxChunkCount) ||
policy.maxChunkCount < 1 ||
policy.maxObjectBytes > policy.chunkSizeBytes * policy.maxChunkCount ||
!Number.isSafeInteger(policy.rpcTimeoutMs) ||
policy.rpcTimeoutMs < 1_000 ||
!Number.isSafeInteger(policy.reconciliationBudgetMs) ||
policy.reconciliationBudgetMs < 1 ||
policy.reconciliationBudgetMs > 60_000 ||
!Number.isSafeInteger(policy.reconciliationBatchSize) ||
policy.reconciliationBatchSize < 1 ||
policy.reconciliationBatchSize > 1_000 ||
!Number.isSafeInteger(policy.orphanGracePeriodMs) ||
policy.orphanGracePeriodMs < 60_000 ||
!Number.isSafeInteger(policy.orphanGcBatchSize) ||
policy.orphanGcBatchSize < 1 ||
policy.orphanGcBatchSize > 1_000 ||
!Number.isSafeInteger(policy.maxCancellationTombstones) ||
policy.maxCancellationTombstones < 16 ||
policy.maxCancellationTombstones > 10_000 ||
typeof policy.isObjectIdAllowed !== "function" ||
typeof policy.isMediaTypeAllowed !== "function"
) {
throw new TypeError("OPFS runtime policy is invalid.");
}
}
export function validateObjectWriteInput(
input: Readonly<{
scope: OpfsStorageScope;
objectId: string;
expectedGeneration: number | null;
mediaType: string;
byteLength: number | null;
storagePolicy: Parameters<typeof assertValidStoragePolicy>[0];
}>,
policy: OpfsRuntimePolicy,
): boolean {
try {
assertValidStoragePolicy(input.storagePolicy);
} catch {
return false;
}
return (
isValidOpfsStorageScope(input.scope) &&
input.scope.namespace === input.storagePolicy.namespace &&
policy.isObjectIdAllowed(input.objectId) &&
policy.isMediaTypeAllowed(input.mediaType) &&
(input.expectedGeneration === null ||
(Number.isSafeInteger(input.expectedGeneration) &&
input.expectedGeneration > 0)) &&
input.byteLength !== null &&
isValidByteLength(input.byteLength) &&
input.byteLength <= policy.maxObjectBytes &&
Math.ceil(input.byteLength / policy.chunkSizeBytes) <=
policy.maxChunkCount
);
}
export function isValidOpfsStorageScope(
scope: OpfsStorageScope,
): boolean {
return (
scope.namespace.length > 0 &&
scope.namespace.length <= 64 &&
OPAQUE_OBJECT_ID.test(scope.authorityToken) &&
OPAQUE_OBJECT_ID.test(scope.namespaceToken) &&
OPAQUE_OBJECT_ID.test(scope.partitionToken)
);
}
/**
* Captures the registry binding at composition time. Callers may own mutable
* config objects, so no OPFS operation is allowed to retain those references.
*/
export function snapshotOpfsStorageScope(
input: OpfsStorageScope,
): OpfsStorageScope {
try {
const snapshot: OpfsStorageScope = Object.freeze({
namespace: input.namespace,
authorityToken: input.authorityToken,
namespaceToken: input.namespaceToken,
partitionToken: input.partitionToken,
});
if (!isValidOpfsStorageScope(snapshot)) throw new TypeError();
return snapshot;
} catch {
throw new TypeError("OPFS storage scope is invalid.");
}
}
/**
* Deep enough for the closed BrowserStoragePolicy contract: retention is the
* only nested value. Fields are copied explicitly so later caller mutation or
* extension properties cannot alter the bound policy fingerprint.
*/
export function snapshotOpfsStoragePolicy(
input: BrowserStoragePolicy,
): BrowserStoragePolicy {
try {
const retention: BrowserStoragePolicy["retention"] =
input.retention.kind === "TTL"
? Object.freeze({
kind: "TTL",
maxAgeMs: input.retention.maxAgeMs,
})
: Object.freeze({ kind: input.retention.kind });
const snapshot: BrowserStoragePolicy = Object.freeze({
owner: input.owner,
namespace: input.namespace,
classification: input.classification,
authority: input.authority,
accountScope: input.accountScope,
retention,
softBudgetBytes: input.softBudgetBytes,
hardBudgetBytes: input.hardBudgetBytes,
evictionPriority: input.evictionPriority,
logoutAction: input.logoutAction,
accountDeletionAction: input.accountDeletionAction,
pressureAction: input.pressureAction,
unavailableFallback: input.unavailableFallback,
});
assertValidStoragePolicy(snapshot);
return snapshot;
} catch {
throw new TypeError("OPFS storage policy is invalid.");
}
}
export function byteBucket(
byteLength: number,
): NonNullable<OpfsSafeObservation["byteBucket"]> {
if (byteLength === 0) return "0";
if (byteLength <= 1024 * 1024) return "1B_1MiB";
if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB";
if (byteLength <= 256 * 1024 * 1024) return "16MiB_256MiB";
return "GT_256MiB";
}
export function transactionBucket(
count: number,
): NonNullable<OpfsSafeObservation["transactionBucket"]> {
if (count === 0) return "0";
if (count <= 10) return "1_10";
if (count <= 100) return "11_100";
return "GT_100";
}
export function observeOpfsSafely(
observer: OpfsSafeObserver | undefined,
observation: OpfsSafeObservation,
): void {
try {
observer?.(Object.freeze({ ...observation }));
} catch {
// Persistence behavior never depends on observability.
}
}
@@ -0,0 +1,636 @@
import type {
OpfsCapabilities,
OpfsPreparedObject,
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
import type {
BrowserDataFailureCode,
BrowserDataOperation,
BrowserDataResult,
ByteSource,
} from "../../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import type { OpfsRuntimePolicy } from "./opfs-policy.ts";
import type {
OpfsWorkerGateway,
OpfsOrphanCandidateBatch,
OpfsOrphanDeleteResult,
OpfsWorkerRequest,
OpfsWorkerRequestBody,
OpfsWorkerResponse,
PreparePhysicalObjectRequest,
} from "./opfs-worker-protocol.ts";
export interface OpfsWorkerLike {
postMessage(message: OpfsWorkerRequest, transfer?: readonly Transferable[]): void;
addEventListener(
type: "message",
listener: (event: MessageEvent<unknown>) => void,
): void;
removeEventListener(
type: "message",
listener: (event: MessageEvent<unknown>) => void,
): void;
}
export type OpfsWorkerClientDependencies = Readonly<{
worker: OpfsWorkerLike;
policy: OpfsRuntimePolicy;
createRequestId?: () => string;
}>;
export type OwnedOpfsWorkerClient = Readonly<{
gateway: OpfsWorkerGateway;
terminate(): void;
}>;
type PendingRequest = Readonly<{
resolve: (response: OpfsWorkerResponse) => void;
reject: (error: OpfsRpcError) => void;
timeout: ReturnType<typeof setTimeout>;
removeAbortListener: () => void;
}>;
class OpfsRpcError extends Error {
readonly code: BrowserDataFailureCode;
constructor(code: BrowserDataFailureCode) {
super("OPFS worker request failed.");
this.name = "OpfsRpcError";
this.code = code;
}
}
export function createOpfsWorkerGateway(
dependencies: OpfsWorkerClientDependencies,
): OpfsWorkerGateway {
const createRequestId =
dependencies.createRequestId ??
(() => globalThis.crypto.randomUUID());
const pending = new Map<string, PendingRequest>();
let disposed = false;
const onMessage = (event: MessageEvent<unknown>): void => {
if (disposed) return;
if (!isWorkerResponse(event.data)) return;
const request = pending.get(event.data.requestId);
if (!request) return;
pending.delete(event.data.requestId);
clearTimeout(request.timeout);
request.removeAbortListener();
request.resolve(event.data);
};
dependencies.worker.addEventListener("message", onMessage);
async function rpc(
request: OpfsWorkerRequestBody,
signal?: AbortSignal,
transfer: readonly Transferable[] = [],
): Promise<OpfsWorkerResponse> {
if (disposed) throw new OpfsRpcError("UNAVAILABLE");
if (signal?.aborted) throw new OpfsRpcError("ABORTED");
const requestId = createRequestId();
const message = { ...request, requestId } as OpfsWorkerRequest;
return await new Promise<OpfsWorkerResponse>((resolve, reject) => {
const abort = (): void => {
const item = pending.get(requestId);
if (!item) return;
pending.delete(requestId);
clearTimeout(item.timeout);
item.removeAbortListener();
reject(new OpfsRpcError("ABORTED"));
};
signal?.addEventListener("abort", abort, { once: true });
const timeout = setTimeout(() => {
const item = pending.get(requestId);
if (!item) return;
pending.delete(requestId);
item.removeAbortListener();
reject(new OpfsRpcError("UNAVAILABLE"));
}, dependencies.policy.rpcTimeoutMs);
pending.set(requestId, {
resolve,
reject,
timeout,
removeAbortListener: () =>
signal?.removeEventListener("abort", abort),
});
try {
dependencies.worker.postMessage(message, transfer);
} catch {
const item = pending.get(requestId);
if (item) {
pending.delete(requestId);
clearTimeout(item.timeout);
item.removeAbortListener();
}
reject(new OpfsRpcError("UNAVAILABLE"));
}
});
}
async function invoke<Value>(
operation: BrowserDataOperation,
request: OpfsWorkerRequestBody,
signal?: AbortSignal,
transfer: readonly Transferable[] = [],
parse?: (value: unknown) => Value | null,
): Promise<BrowserDataResult<Value>> {
try {
const response = await rpc(request, signal, transfer);
if (!response.ok) {
return failureResult(response.failure.code, operation);
}
const parsed = parse?.(response.value);
if (parse && parsed === null) {
return browserDataFailure("CORRUPT_DATA", operation, {
recovery: "REHYDRATE",
});
}
return browserDataSuccess(parsed as Value);
} catch (error) {
return failureResult(
error instanceof OpfsRpcError ? error.code : "UNAVAILABLE",
operation,
);
}
}
async function abortAndCleanup(
scope: OpfsPreparedObject["descriptor"]["scope"],
transactionId: string,
): Promise<void> {
try {
await rpc({ kind: "ABORT_PUT", scope, transactionId });
} catch {
// Journal reconciliation repeats cleanup after a crash or timeout.
}
}
return Object.freeze({
async capabilities() {
return await invoke(
"OBJECT_READ",
{ kind: "CAPABILITIES" },
undefined,
[],
parseCapabilities,
);
},
async preparePut(request: PreparePhysicalObjectRequest) {
const begin = await invoke<void>(
"OBJECT_WRITE",
{
kind: "BEGIN_PUT",
transactionId: request.transactionId,
scope: request.descriptor.scope,
objectId: request.descriptor.objectId,
generation: request.descriptor.generation,
declaredByteLength: request.descriptor.byteLength,
mediaType: request.descriptor.mediaType,
createdAtEpochMs: request.descriptor.createdAtEpochMs,
storagePolicy: request.descriptor.storagePolicy,
chunkSizeBytes: dependencies.policy.chunkSizeBytes,
},
request.signal,
);
if (!begin.ok) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
return begin;
}
let sequence = 0;
let transferredBytes = 0;
notifyProgress(request, "TRANSFERRING", 0);
try {
for await (const chunk of rechunk(
request.source.stream(requiredSignal(request.signal)),
dependencies.policy.chunkSizeBytes,
dependencies.policy.maxObjectBytes,
request.signal,
)) {
const chunkByteLength = chunk.byteLength;
const append = await invoke<void>(
"OBJECT_WRITE",
{
kind: "APPEND_CHUNK",
scope: request.descriptor.scope,
transactionId: request.transactionId,
sequence,
bytes: chunk,
},
request.signal,
[chunk],
);
if (!append.ok) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
return append;
}
sequence += 1;
transferredBytes += chunkByteLength;
notifyProgress(request, "TRANSFERRING", transferredBytes);
}
if (transferredBytes !== request.descriptor.byteLength) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
return browserDataFailure("INTEGRITY_FAILED", "OBJECT_WRITE", {
recovery: "RESELECT",
});
}
notifyProgress(request, "VERIFYING", transferredBytes);
const finished = await invoke(
"OBJECT_WRITE",
{
kind: "FINISH_PUT",
scope: request.descriptor.scope,
transactionId: request.transactionId,
},
request.signal,
[],
parsePreparedObject,
);
if (!finished.ok) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
}
return finished;
} catch (error) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
return failureResult(
error instanceof OpfsRpcError ? error.code : "NOT_READABLE",
"OBJECT_WRITE",
);
}
},
async verifyObject(
preparedObject: OpfsPreparedObject,
signal?: AbortSignal,
) {
return await invoke(
"OBJECT_READ",
{ kind: "VERIFY_OBJECT", preparedObject },
signal,
[],
(value) => (typeof value === "boolean" ? value : null),
);
},
async openObject(
preparedObject: OpfsPreparedObject,
signal?: AbortSignal,
) {
const verified = await invoke(
"OBJECT_READ",
{ kind: "VERIFY_OBJECT", preparedObject },
signal,
[],
(value) => (typeof value === "boolean" ? value : null),
);
if (!verified.ok) return verified;
if (!verified.value) {
return browserDataFailure("INTEGRITY_FAILED", "OBJECT_READ", {
recovery: "REHYDRATE",
});
}
const source: ByteSource = Object.freeze({
byteLength: preparedObject.descriptor.byteLength,
async *stream(streamSignal: AbortSignal) {
for (const chunk of preparedObject.chunks) {
if (streamSignal.aborted) {
yield browserDataFailure("ABORTED", "OBJECT_READ");
return;
}
const result = await invoke(
"OBJECT_READ",
{
kind: "READ_CHUNK",
preparedObject,
sequence: chunk.sequence,
},
streamSignal,
[],
(value) => (value instanceof ArrayBuffer ? value : null),
);
if (!result.ok) {
yield result;
return;
}
yield browserDataSuccess(new Uint8Array(result.value));
}
},
});
return browserDataSuccess(source);
},
async removeObject(
scope: OpfsPreparedObject["descriptor"]["scope"],
objectId: string,
generation: number,
signal?: AbortSignal,
) {
return await invoke<void>(
"OBJECT_DELETE",
{ kind: "REMOVE_OBJECT", scope, objectId, generation },
signal,
);
},
async cleanupTransaction(
scope: OpfsPreparedObject["descriptor"]["scope"],
transactionId: string,
signal?: AbortSignal,
) {
return await invoke<void>(
"OBJECT_RECONCILE",
{ kind: "CLEANUP_TRANSACTION", scope, transactionId },
signal,
);
},
async finalizePut(
transactionId: string,
preparedObject: OpfsPreparedObject,
signal?: AbortSignal,
) {
return await invoke<void>(
"OBJECT_RECONCILE",
{ kind: "FINALIZE_PUT", transactionId, preparedObject },
signal,
);
},
async listOrphanCandidates(
scope: OpfsPreparedObject["descriptor"]["scope"],
olderThanEpochMs: number,
maxEntries: number,
signal?: AbortSignal,
) {
return await invoke(
"OBJECT_RECONCILE",
{
kind: "LIST_ORPHAN_CANDIDATES",
scope,
olderThanEpochMs,
maxEntries,
},
signal,
[],
parseOrphanCandidateBatch,
);
},
async deleteOrphanChunk(
scope: OpfsPreparedObject["descriptor"]["scope"],
digestHex: string,
olderThanEpochMs: number,
signal?: AbortSignal,
) {
return await invoke(
"OBJECT_RECONCILE",
{
kind: "DELETE_ORPHAN_CHUNK",
scope,
digestHex,
olderThanEpochMs,
},
signal,
[],
parseOrphanDeleteResult,
);
},
close() {
if (disposed) return;
disposed = true;
dependencies.worker.removeEventListener("message", onMessage);
for (const request of pending.values()) {
clearTimeout(request.timeout);
request.removeAbortListener();
request.reject(new OpfsRpcError("UNAVAILABLE"));
}
pending.clear();
},
});
}
export function createOwnedOpfsWorkerClient(
dependencies: Readonly<{
workerUrl: string | URL;
policy: OpfsRuntimePolicy;
workerName?: string;
createRequestId?: () => string;
}>,
): OwnedOpfsWorkerClient {
const worker = new Worker(dependencies.workerUrl, {
type: "module",
name: dependencies.workerName ?? "ca-opfs-byte-store",
});
const gateway = createOpfsWorkerGateway({
worker,
policy: dependencies.policy,
createRequestId: dependencies.createRequestId,
});
return Object.freeze({
gateway,
terminate: () => {
gateway.close();
worker.terminate();
},
});
}
async function* rechunk(
source: AsyncIterable<BrowserDataResult<Uint8Array>>,
chunkSize: number,
maxBytes: number,
signal: AbortSignal | undefined,
): AsyncGenerator<ArrayBuffer> {
let target = new Uint8Array(chunkSize);
let targetOffset = 0;
let totalBytes = 0;
for await (const sourceResult of source) {
if (signal?.aborted) throw new OpfsRpcError("ABORTED");
if (!sourceResult.ok) {
throw new OpfsRpcError(sourceResult.error.code);
}
const sourceChunk = sourceResult.value;
if (!(sourceChunk instanceof Uint8Array)) {
throw new OpfsRpcError("CORRUPT_DATA");
}
let sourceOffset = 0;
totalBytes += sourceChunk.byteLength;
if (!Number.isSafeInteger(totalBytes) || totalBytes > maxBytes) {
throw new OpfsRpcError("LIMIT_EXCEEDED");
}
while (sourceOffset < sourceChunk.byteLength) {
const copyLength = Math.min(
chunkSize - targetOffset,
sourceChunk.byteLength - sourceOffset,
);
target.set(
sourceChunk.subarray(sourceOffset, sourceOffset + copyLength),
targetOffset,
);
sourceOffset += copyLength;
targetOffset += copyLength;
if (targetOffset === chunkSize) {
yield target.buffer as ArrayBuffer;
target = new Uint8Array(chunkSize);
targetOffset = 0;
}
}
}
if (targetOffset > 0) {
yield target.slice(0, targetOffset).buffer as ArrayBuffer;
}
}
function requiredSignal(signal: AbortSignal | undefined): AbortSignal {
return signal ?? new AbortController().signal;
}
function notifyProgress(
request: PreparePhysicalObjectRequest,
phase: "TRANSFERRING" | "VERIFYING",
transferredBytes: number,
): void {
try {
request.onProgress?.({
phase,
transferredBytes,
totalBytes: request.descriptor.byteLength,
});
} catch {
// A UI callback cannot affect the write protocol.
}
}
function failureResult(
code: BrowserDataFailureCode,
operation: BrowserDataOperation,
): BrowserDataResult<never> {
if (code === "ABORTED") return browserDataFailure(code, operation);
if (code === "QUOTA_EXCEEDED") {
return browserDataFailure(code, operation, {
retryable: true,
recovery: "READ_ONLY",
});
}
if (code === "INTEGRITY_FAILED" || code === "CORRUPT_DATA") {
return browserDataFailure(code, operation, { recovery: "REHYDRATE" });
}
if (code === "UNSUPPORTED" || code === "UNAVAILABLE") {
return browserDataFailure(code, operation, {
retryable: code === "UNAVAILABLE",
recovery: "ONLINE_ONLY",
});
}
return browserDataFailure(code, operation, {
retryable: code === "BLOCKED" || code === "NOT_READABLE",
recovery: code === "NOT_FOUND" ? "REHYDRATE" : "RETRY",
});
}
function parseCapabilities(value: unknown): OpfsCapabilities | null {
if (
!value ||
typeof value !== "object" ||
!("available" in value) ||
typeof value.available !== "boolean" ||
!("dedicatedWorkerRequired" in value) ||
value.dedicatedWorkerRequired !== true ||
!("crossContextMutationLockAvailable" in value) ||
typeof value.crossContextMutationLockAvailable !== "boolean" ||
!("synchronousAccessHandleAvailable" in value) ||
typeof value.synchronousAccessHandleAvailable !== "boolean"
) {
return null;
}
return value as OpfsCapabilities;
}
function parsePreparedObject(value: unknown): OpfsPreparedObject | null {
if (
!value ||
typeof value !== "object" ||
!("physicalSchemaVersion" in value) ||
value.physicalSchemaVersion !== 1 ||
!("descriptor" in value) ||
!("chunks" in value) ||
!Array.isArray(value.chunks)
) {
return null;
}
return value as OpfsPreparedObject;
}
function parseOrphanCandidateBatch(
value: unknown,
): OpfsOrphanCandidateBatch | null {
if (
!value ||
typeof value !== "object" ||
!("safeToSweep" in value) ||
typeof value.safeToSweep !== "boolean" ||
!("digests" in value) ||
!Array.isArray(value.digests) ||
!value.digests.every(
(digest) =>
typeof digest === "string" && /^[a-f0-9]{64}$/u.test(digest),
) ||
!("moreAvailable" in value) ||
typeof value.moreAvailable !== "boolean"
) {
return null;
}
return value as OpfsOrphanCandidateBatch;
}
function parseOrphanDeleteResult(
value: unknown,
): OpfsOrphanDeleteResult | null {
if (
!value ||
typeof value !== "object" ||
!("deleted" in value) ||
typeof value.deleted !== "boolean" ||
!("skippedUnsafe" in value) ||
typeof value.skippedUnsafe !== "boolean"
) {
return null;
}
return value as OpfsOrphanDeleteResult;
}
function isWorkerResponse(value: unknown): value is OpfsWorkerResponse {
return Boolean(
value &&
typeof value === "object" &&
"requestId" in value &&
typeof value.requestId === "string" &&
"ok" in value &&
typeof value.ok === "boolean",
);
}
@@ -0,0 +1,194 @@
import type {
DurableObjectDescriptor,
OpfsCapabilities,
OpfsPreparedObject,
OpfsStorageScope,
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
import type {
BrowserDataFailureCode,
BrowserDataResult,
BrowserStoragePolicy,
ByteSource,
TransferProgress,
} from "../../../application/ports/browser-file-storage/shared.ts";
export type OpfsWorkerRequest =
| Readonly<{
requestId: string;
kind: "CAPABILITIES";
}>
| Readonly<{
requestId: string;
kind: "BEGIN_PUT";
transactionId: string;
scope: OpfsStorageScope;
objectId: string;
generation: number;
declaredByteLength: number;
mediaType: string;
createdAtEpochMs: number;
storagePolicy: BrowserStoragePolicy;
chunkSizeBytes: number;
}>
| Readonly<{
requestId: string;
kind: "APPEND_CHUNK";
scope: OpfsStorageScope;
transactionId: string;
sequence: number;
bytes: ArrayBuffer;
}>
| Readonly<{
requestId: string;
kind: "FINISH_PUT";
scope: OpfsStorageScope;
transactionId: string;
}>
| Readonly<{
requestId: string;
kind: "ABORT_PUT";
scope: OpfsStorageScope;
transactionId: string;
}>
| Readonly<{
requestId: string;
kind: "VERIFY_OBJECT";
preparedObject: OpfsPreparedObject;
}>
| Readonly<{
requestId: string;
kind: "READ_CHUNK";
preparedObject: OpfsPreparedObject;
sequence: number;
}>
| Readonly<{
requestId: string;
kind: "REMOVE_OBJECT";
scope: OpfsStorageScope;
objectId: string;
generation: number;
}>
| Readonly<{
requestId: string;
kind: "CLEANUP_TRANSACTION";
scope: OpfsStorageScope;
transactionId: string;
}>
| Readonly<{
requestId: string;
kind: "FINALIZE_PUT";
transactionId: string;
preparedObject: OpfsPreparedObject;
}>
| Readonly<{
requestId: string;
kind: "LIST_ORPHAN_CANDIDATES";
scope: OpfsStorageScope;
olderThanEpochMs: number;
maxEntries: number;
}>
| Readonly<{
requestId: string;
kind: "DELETE_ORPHAN_CHUNK";
scope: OpfsStorageScope;
digestHex: string;
olderThanEpochMs: number;
}>;
export type OpfsWorkerRequestBody =
OpfsWorkerRequest extends infer Request
? Request extends OpfsWorkerRequest
? Omit<Request, "requestId">
: never
: never;
export type OpfsWorkerFailure = Readonly<{
code: BrowserDataFailureCode;
retryable: boolean;
}>;
export type OpfsOrphanCandidateBatch = Readonly<{
safeToSweep: boolean;
digests: readonly string[];
moreAvailable: boolean;
}>;
export type OpfsOrphanDeleteResult = Readonly<{
deleted: boolean;
skippedUnsafe: boolean;
}>;
export type OpfsWorkerResponse =
| Readonly<{
requestId: string;
ok: true;
value?:
| OpfsCapabilities
| OpfsPreparedObject
| ArrayBuffer
| boolean
| OpfsOrphanCandidateBatch
| OpfsOrphanDeleteResult;
}>
| Readonly<{
requestId: string;
ok: false;
failure: OpfsWorkerFailure;
}>;
export type PreparePhysicalObjectRequest = Readonly<{
transactionId: string;
descriptor: Omit<DurableObjectDescriptor, "integrity">;
source: ByteSource;
signal?: AbortSignal;
onProgress?: (progress: TransferProgress) => void;
}>;
/**
* The coordinator depends on this technology-neutral worker gateway. The
* browser implementation below the boundary owns Worker, MessageEvent and
* transferable ArrayBuffer instances.
*/
export interface OpfsWorkerGateway {
capabilities(): Promise<BrowserDataResult<OpfsCapabilities>>;
preparePut(
request: PreparePhysicalObjectRequest,
): Promise<BrowserDataResult<OpfsPreparedObject>>;
verifyObject(
preparedObject: OpfsPreparedObject,
signal?: AbortSignal,
): Promise<BrowserDataResult<boolean>>;
openObject(
preparedObject: OpfsPreparedObject,
signal?: AbortSignal,
): Promise<BrowserDataResult<ByteSource>>;
removeObject(
scope: OpfsStorageScope,
objectId: string,
generation: number,
signal?: AbortSignal,
): Promise<BrowserDataResult<void>>;
cleanupTransaction(
scope: OpfsStorageScope,
transactionId: string,
signal?: AbortSignal,
): Promise<BrowserDataResult<void>>;
finalizePut(
transactionId: string,
preparedObject: OpfsPreparedObject,
signal?: AbortSignal,
): Promise<BrowserDataResult<void>>;
listOrphanCandidates(
scope: OpfsStorageScope,
olderThanEpochMs: number,
maxEntries: number,
signal?: AbortSignal,
): Promise<BrowserDataResult<OpfsOrphanCandidateBatch>>;
deleteOrphanChunk(
scope: OpfsStorageScope,
digestHex: string,
olderThanEpochMs: number,
signal?: AbortSignal,
): Promise<BrowserDataResult<OpfsOrphanDeleteResult>>;
close(): void;
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,33 @@
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
import { queueSizeBucket } from "../../contracts/diagnostics.js";
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
import { queueSizeBucket } from "../../contracts/diagnostic-buckets.ts";
import type {
TelemetryEvent,
TelemetryEventName,
} from "../../contracts/telemetry.ts";
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
export const noOpTelemetry = Object.freeze({
export type TelemetryAdapter = TelemetryPort &
Readonly<{
flush(): Promise<void>;
pendingCount(): number;
droppedCount(): number;
dropReasons(): Readonly<Record<string, number>>;
deliveryEvidence(): TelemetryEvent | null;
dispose(): void;
}>;
export type TelemetryAdapterOptions = Readonly<{
enabled: boolean;
endpoint?: string;
fetcher?: typeof fetch;
maxQueue?: number;
schedule?: (callback: () => void) => void;
now?: () => number;
onDrop?: (event: TelemetryEvent) => void;
lifecycle?: Pick<EventTarget, "addEventListener" | "removeEventListener">;
}>;
export const noOpTelemetry: TelemetryAdapter = Object.freeze({
emit: () => {},
flush: async () => {},
pendingCount: () => 0,
@@ -11,37 +37,23 @@ export const noOpTelemetry = Object.freeze({
dispose: () => {},
});
/**
* @param {{
* enabled: boolean,
* endpoint?: string,
* fetcher?: typeof fetch,
* maxQueue?: number,
* schedule?: (callback: () => void) => void,
* now?: () => number,
* onDrop?: (event: Readonly<Record<string, unknown>>) => void,
* lifecycle?: Pick<EventTarget, "addEventListener" | "removeEventListener">
* }} options
*/
export function createTelemetryAdapter(options) {
export function createTelemetryAdapter(
options: TelemetryAdapterOptions,
): TelemetryAdapter {
if (!options.enabled || !options.endpoint) {
return noOpTelemetry;
}
const endpoint = /** @type {string} */ (options.endpoint);
const endpoint = options.endpoint;
const fetcher = options.fetcher ?? fetch;
const maxQueue = Math.max(1, options.maxQueue ?? 100);
const schedule = options.schedule ?? queueMicrotask;
const queue =
/** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ (
[]
);
const queue: TelemetryEvent[] = [];
let scheduled = false;
let flushing = false;
let dropped = 0;
const dropReasons = new Map();
let lastDeliveryEvidence =
/** @type {Readonly<Record<string, unknown>> | null} */ (null);
const dropReasons = new Map<string, number>();
let lastDeliveryEvidence: TelemetryEvent | null = null;
const lifecycle =
options.lifecycle ??
(typeof globalThis.addEventListener === "function" &&
@@ -49,8 +61,7 @@ export function createTelemetryAdapter(options) {
? globalThis
: undefined);
/** @param {string} reason @param {number} count */
function recordDrop(reason, count = 1) {
function recordDrop(reason: string, count = 1): void {
const safeReason =
{
"queue-full": "queue-full",
@@ -81,8 +92,19 @@ export function createTelemetryAdapter(options) {
}
}
/** @param {string} eventName @param {Record<string, unknown>} attributes */
function emit(eventName, attributes) {
function scheduleFlush(): void {
if (scheduled) return;
scheduled = true;
schedule(() => {
scheduled = false;
void flush();
});
}
function emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void {
const projected = projectTelemetryEvent(
eventName,
attributes,
@@ -99,16 +121,10 @@ export function createTelemetryAdapter(options) {
}
queue.push(projected.event);
if (!scheduled) {
scheduled = true;
schedule(() => {
scheduled = false;
void flush();
});
}
scheduleFlush();
}
async function flush() {
async function flush(): Promise<void> {
if (flushing || queue.length === 0) return;
flushing = true;
const batch = queue.splice(0, queue.length);
@@ -124,6 +140,9 @@ export function createTelemetryAdapter(options) {
recordDrop("sink-failure", batch.length);
} finally {
flushing = false;
if (queue.length > 0) {
scheduleFlush();
}
}
}
@@ -132,7 +151,7 @@ export function createTelemetryAdapter(options) {
};
lifecycle?.addEventListener("pagehide", flushBeforePageExit);
function dispose() {
function dispose(): void {
lifecycle?.removeEventListener("pagehide", flushBeforePageExit);
}
@@ -152,9 +171,10 @@ export function createTelemetryAdapter(options) {
* Propagates only a structurally valid W3C traceparent. Invalid/raw headers are
* discarded rather than logged or surfaced.
*
* @param {string | null | undefined} traceparent
*/
export function safeTraceparent(traceparent) {
export function safeTraceparent(
traceparent: string | null | undefined,
): string | null {
return typeof traceparent === "string" &&
/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/i.test(traceparent)
? traceparent.toLowerCase()

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