chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -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";
|
||||
@@ -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 }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
createDefaultPublicCachePolicy,
|
||||
resolvePublicCachePolicy,
|
||||
type PublicCacheRuntimePolicy,
|
||||
type PublicCacheSafeObservation,
|
||||
type PublicCacheSafeObserver,
|
||||
} from "./public-cache-policy.ts";
|
||||
export {
|
||||
computePublicCacheManifestDigestHex,
|
||||
createPublicCacheWebLock,
|
||||
createPublicResponseCacheAdapter,
|
||||
type PublicCacheMutationLock,
|
||||
type PublicResponseCacheDependencies,
|
||||
} from "./public-response-cache-adapter.ts";
|
||||
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataOperation,
|
||||
} from "../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
export type PublicCacheRuntimePolicy = Readonly<{
|
||||
origin: string;
|
||||
ownedCachePrefix: string;
|
||||
mutationLockName: string;
|
||||
maxEntryBytes: number;
|
||||
maxReleaseBytes: number;
|
||||
maxEntriesPerRelease: number;
|
||||
retainedPreviousReleaseCount: number;
|
||||
allowedRequestHeaderNames: readonly string[];
|
||||
allowedVaryHeaderNames: readonly string[];
|
||||
allowedResponseHeaderNames: readonly string[];
|
||||
unknownResponseHeaderAction: "REJECT" | "STRIP";
|
||||
allowedQueryParameterNames: readonly string[];
|
||||
forbiddenQueryParameterNames: readonly string[];
|
||||
isQueryParameterValueAllowed: (name: string, value: string) => boolean;
|
||||
isContentTypeAllowed: (contentType: string) => boolean;
|
||||
isReleaseRegistryIdAllowed: (releaseRegistryId: string) => boolean;
|
||||
}>;
|
||||
|
||||
export type PublicCacheSafeObservation = Readonly<{
|
||||
operation: BrowserDataOperation;
|
||||
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
|
||||
failureCode?: BrowserDataFailureCode;
|
||||
releaseRegistryId?: string;
|
||||
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "GT_16MiB";
|
||||
entryBucket?: "0" | "1_10" | "11_100" | "GT_100";
|
||||
}>;
|
||||
|
||||
export type PublicCacheSafeObserver = (
|
||||
observation: PublicCacheSafeObservation,
|
||||
) => void;
|
||||
|
||||
const RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
||||
const CACHE_PREFIX = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,63}:$/u;
|
||||
const DEFAULT_PUBLIC_CONTENT_TYPE =
|
||||
/^(?:application\/(?:javascript|json|manifest\+json|wasm)|font\/[a-z0-9.+-]+|image\/[a-z0-9.+-]+|text\/(?:css|javascript|plain))(?:\s*;.*)?$/iu;
|
||||
|
||||
export function createDefaultPublicCachePolicy(
|
||||
origin: string,
|
||||
): PublicCacheRuntimePolicy {
|
||||
return resolvePublicCachePolicy({
|
||||
origin,
|
||||
ownedCachePrefix: "ca-public-v1:",
|
||||
mutationLockName: "ca-public-v1:mutation",
|
||||
maxEntryBytes: 16 * 1024 * 1024,
|
||||
maxReleaseBytes: 128 * 1024 * 1024,
|
||||
maxEntriesPerRelease: 500,
|
||||
retainedPreviousReleaseCount: 1,
|
||||
allowedRequestHeaderNames: ["accept", "accept-language"],
|
||||
allowedVaryHeaderNames: [],
|
||||
allowedResponseHeaderNames: [
|
||||
"cache-control",
|
||||
"content-language",
|
||||
"content-type",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"vary",
|
||||
],
|
||||
unknownResponseHeaderAction: "STRIP",
|
||||
allowedQueryParameterNames: [],
|
||||
forbiddenQueryParameterNames: [
|
||||
"access_token",
|
||||
"api_key",
|
||||
"auth",
|
||||
"email",
|
||||
"jwt",
|
||||
"session",
|
||||
"token",
|
||||
"user",
|
||||
],
|
||||
isQueryParameterValueAllowed: () => false,
|
||||
isContentTypeAllowed: (contentType) =>
|
||||
DEFAULT_PUBLIC_CONTENT_TYPE.test(contentType),
|
||||
isReleaseRegistryIdAllowed: (releaseRegistryId) =>
|
||||
RELEASE_ID.test(releaseRegistryId),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePublicCachePolicy(
|
||||
policy: PublicCacheRuntimePolicy,
|
||||
): PublicCacheRuntimePolicy {
|
||||
const normalized: PublicCacheRuntimePolicy = Object.freeze({
|
||||
...policy,
|
||||
origin: new URL(policy.origin).origin,
|
||||
allowedRequestHeaderNames: Object.freeze(
|
||||
policy.allowedRequestHeaderNames.map((name) => name.toLowerCase()),
|
||||
),
|
||||
allowedVaryHeaderNames: Object.freeze(
|
||||
policy.allowedVaryHeaderNames.map((name) => name.toLowerCase()),
|
||||
),
|
||||
allowedResponseHeaderNames: Object.freeze(
|
||||
policy.allowedResponseHeaderNames.map((name) => name.toLowerCase()),
|
||||
),
|
||||
allowedQueryParameterNames: Object.freeze(
|
||||
policy.allowedQueryParameterNames.map((name) => name.toLowerCase()),
|
||||
),
|
||||
forbiddenQueryParameterNames: Object.freeze(
|
||||
policy.forbiddenQueryParameterNames.map((name) => name.toLowerCase()),
|
||||
),
|
||||
});
|
||||
assertPublicCachePolicy(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function assertPublicCachePolicy(
|
||||
policy: PublicCacheRuntimePolicy,
|
||||
): void {
|
||||
const origin = new URL(policy.origin);
|
||||
if (
|
||||
origin.origin !== policy.origin ||
|
||||
!isAllowedPublicCacheOrigin(origin) ||
|
||||
!CACHE_PREFIX.test(policy.ownedCachePrefix) ||
|
||||
policy.mutationLockName.length === 0 ||
|
||||
!positiveSafeInteger(policy.maxEntryBytes) ||
|
||||
!positiveSafeInteger(policy.maxReleaseBytes) ||
|
||||
policy.maxEntryBytes > policy.maxReleaseBytes ||
|
||||
!positiveSafeInteger(policy.maxEntriesPerRelease) ||
|
||||
policy.maxEntriesPerRelease > 10_000 ||
|
||||
!Number.isSafeInteger(policy.retainedPreviousReleaseCount) ||
|
||||
policy.retainedPreviousReleaseCount < 1 ||
|
||||
policy.retainedPreviousReleaseCount > 5 ||
|
||||
!headerNameList(policy.allowedRequestHeaderNames) ||
|
||||
!headerNameList(policy.allowedVaryHeaderNames) ||
|
||||
!headerNameList(policy.allowedResponseHeaderNames) ||
|
||||
!["REJECT", "STRIP"].includes(policy.unknownResponseHeaderAction) ||
|
||||
!queryNameList(policy.allowedQueryParameterNames) ||
|
||||
policy.allowedVaryHeaderNames.some(
|
||||
(name) => !policy.allowedRequestHeaderNames.includes(name),
|
||||
) ||
|
||||
policy.forbiddenQueryParameterNames.some((name) => name.length === 0) ||
|
||||
policy.allowedQueryParameterNames.some((name) =>
|
||||
policy.forbiddenQueryParameterNames.includes(name),
|
||||
)
|
||||
) {
|
||||
throw new TypeError("Public Cache Storage policy is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
export function isAllowedPublicCacheOrigin(url: URL): boolean {
|
||||
return (
|
||||
url.protocol === "https:" ||
|
||||
(url.protocol === "http:" &&
|
||||
(url.hostname === "localhost" ||
|
||||
url.hostname === "[::1]" ||
|
||||
/^127(?:\.\d{1,3}){3}$/u.test(url.hostname)))
|
||||
);
|
||||
}
|
||||
|
||||
export function cacheByteBucket(
|
||||
byteLength: number,
|
||||
): NonNullable<PublicCacheSafeObservation["byteBucket"]> {
|
||||
if (byteLength === 0) return "0";
|
||||
if (byteLength <= 1024 * 1024) return "1B_1MiB";
|
||||
if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB";
|
||||
return "GT_16MiB";
|
||||
}
|
||||
|
||||
export function cacheEntryBucket(
|
||||
count: number,
|
||||
): NonNullable<PublicCacheSafeObservation["entryBucket"]> {
|
||||
if (count === 0) return "0";
|
||||
if (count <= 10) return "1_10";
|
||||
if (count <= 100) return "11_100";
|
||||
return "GT_100";
|
||||
}
|
||||
|
||||
export function observePublicCacheSafely(
|
||||
observer: PublicCacheSafeObserver | undefined,
|
||||
observation: PublicCacheSafeObservation,
|
||||
): void {
|
||||
try {
|
||||
observer?.(Object.freeze({ ...observation }));
|
||||
} catch {
|
||||
// Cache behavior never depends on observability.
|
||||
}
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function headerNameList(names: readonly string[]): boolean {
|
||||
return (
|
||||
new Set(names).size === names.length &&
|
||||
names.every((name) => /^[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(name))
|
||||
);
|
||||
}
|
||||
|
||||
function queryNameList(names: readonly string[]): boolean {
|
||||
return (
|
||||
new Set(names).size === names.length &&
|
||||
names.every((name) => /^[a-z0-9][a-z0-9._-]{0,63}$/u.test(name))
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
isCacheInvalidationOpaqueIdentifier,
|
||||
type CacheInvalidationTopicDefinition,
|
||||
} from "../../contracts/cache-invalidation.ts";
|
||||
import {
|
||||
createBrowserCrossContextInvalidation,
|
||||
type BroadcastChannelFacade,
|
||||
type BroadcastMessageListener,
|
||||
type BrowserCrossContextInvalidation,
|
||||
type CrossContextInvalidationObservation,
|
||||
type StorageEventTargetFacade,
|
||||
type StoragePulseFacade,
|
||||
type StoragePulseListener,
|
||||
} from "./browser-cross-context-invalidation.ts";
|
||||
|
||||
export type BrowserCrossContextHostDependencies = Readonly<{
|
||||
host?: Record<string, unknown>;
|
||||
cacheEpoch: string;
|
||||
topics: readonly CacheInvalidationTopicDefinition[];
|
||||
observe?: (observation: CrossContextInvalidationObservation) => void;
|
||||
}>;
|
||||
|
||||
type NativeBroadcastChannel = Readonly<{
|
||||
postMessage(value: unknown): void;
|
||||
addEventListener(type: string, listener: (event: unknown) => void): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: (event: unknown) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
const CHANNEL_NAME = "ca-client-cache-invalidation-v1";
|
||||
const STORAGE_PULSE_KEY =
|
||||
"ca-frontend:cache-invalidation:v1:pulse";
|
||||
|
||||
/**
|
||||
* Captures native capabilities without allowing a SecurityError getter or a
|
||||
* missing random source to fail application boot.
|
||||
*/
|
||||
export function createBrowserCrossContextInvalidationFromHost(
|
||||
dependencies: BrowserCrossContextHostDependencies,
|
||||
): BrowserCrossContextInvalidation | undefined {
|
||||
// A zero-feature build owns no cross-context invalidation runtime. Preserve
|
||||
// that property strictly: do not even probe browser capability getters.
|
||||
if (dependencies.topics.length === 0) return undefined;
|
||||
const host =
|
||||
dependencies.host ??
|
||||
(globalThis as unknown as Record<string, unknown>);
|
||||
if (
|
||||
!isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const createOpaqueId = randomIdFactory(host);
|
||||
if (!createOpaqueId) return undefined;
|
||||
|
||||
const sourceId = createOpaqueId("tab");
|
||||
const sourceEpoch = createOpaqueId("page");
|
||||
if (!sourceId || !sourceEpoch) return undefined;
|
||||
|
||||
return createBrowserCrossContextInvalidation({
|
||||
channelName: CHANNEL_NAME,
|
||||
storagePulseKey: STORAGE_PULSE_KEY,
|
||||
sourceId,
|
||||
sourceEpoch,
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topics: dependencies.topics,
|
||||
createEventId: () => {
|
||||
const eventId = createOpaqueId("event");
|
||||
if (!eventId) throw new TypeError("Secure random is unavailable.");
|
||||
return eventId;
|
||||
},
|
||||
createBroadcastChannel: broadcastFactory(host),
|
||||
storage: storageFacade(host),
|
||||
storageEvents: storageEventTarget(host),
|
||||
observe: dependencies.observe,
|
||||
});
|
||||
}
|
||||
|
||||
function safeGet(
|
||||
target: Record<string, unknown>,
|
||||
property: string,
|
||||
): unknown {
|
||||
try {
|
||||
return Reflect.get(target, property);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function randomIdFactory(
|
||||
host: Record<string, unknown>,
|
||||
): ((prefix: string) => string | null) | undefined {
|
||||
const cryptoCandidate = safeGet(host, "crypto");
|
||||
if (!cryptoCandidate || typeof cryptoCandidate !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const randomUuid = safeGet(
|
||||
cryptoCandidate as Record<string, unknown>,
|
||||
"randomUUID",
|
||||
);
|
||||
if (typeof randomUuid !== "function") return undefined;
|
||||
|
||||
return (prefix) => {
|
||||
try {
|
||||
const value = Reflect.apply(randomUuid, cryptoCandidate, []);
|
||||
if (typeof value !== "string") return null;
|
||||
const candidate = `${prefix}.${value}`;
|
||||
return isCacheInvalidationOpaqueIdentifier(candidate)
|
||||
? candidate
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastFactory(
|
||||
host: Record<string, unknown>,
|
||||
):
|
||||
| ((name: string) => BroadcastChannelFacade)
|
||||
| undefined {
|
||||
const Constructor = safeGet(host, "BroadcastChannel");
|
||||
if (typeof Constructor !== "function") return undefined;
|
||||
|
||||
return (name) => {
|
||||
const candidate = Reflect.construct(Constructor, [name]) as unknown;
|
||||
if (!isNativeBroadcastChannel(candidate)) {
|
||||
throw new TypeError("BroadcastChannel is incompatible.");
|
||||
}
|
||||
const listenerBindings = new Map<
|
||||
BroadcastMessageListener,
|
||||
(event: unknown) => void
|
||||
>();
|
||||
return Object.freeze({
|
||||
postMessage(value: unknown) {
|
||||
candidate.postMessage(value);
|
||||
},
|
||||
addEventListener(
|
||||
_type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
) {
|
||||
const bound = (event: unknown) => {
|
||||
listener({
|
||||
data:
|
||||
event && typeof event === "object"
|
||||
? safeGet(
|
||||
event as Record<string, unknown>,
|
||||
"data",
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
listenerBindings.set(listener, bound);
|
||||
candidate.addEventListener("message", bound);
|
||||
},
|
||||
removeEventListener(
|
||||
_type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
) {
|
||||
const bound = listenerBindings.get(listener);
|
||||
if (!bound) return;
|
||||
listenerBindings.delete(listener);
|
||||
candidate.removeEventListener("message", bound);
|
||||
},
|
||||
close() {
|
||||
listenerBindings.clear();
|
||||
candidate.close();
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function isNativeBroadcastChannel(
|
||||
value: unknown,
|
||||
): value is NativeBroadcastChannel {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return ["postMessage", "addEventListener", "removeEventListener", "close"].every(
|
||||
(method) => typeof safeGet(candidate, method) === "function",
|
||||
);
|
||||
}
|
||||
|
||||
function storageFacade(
|
||||
host: Record<string, unknown>,
|
||||
): StoragePulseFacade | undefined {
|
||||
const candidate = safeGet(host, "localStorage");
|
||||
if (!candidate || typeof candidate !== "object") return undefined;
|
||||
const record = candidate as Record<string, unknown>;
|
||||
const setItem = safeGet(record, "setItem");
|
||||
const removeItem = safeGet(record, "removeItem");
|
||||
if (typeof setItem !== "function" || typeof removeItem !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
setItem(key, value) {
|
||||
Reflect.apply(setItem, candidate, [key, value]);
|
||||
},
|
||||
removeItem(key) {
|
||||
Reflect.apply(removeItem, candidate, [key]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function storageEventTarget(
|
||||
host: Record<string, unknown>,
|
||||
): StorageEventTargetFacade | undefined {
|
||||
const addEventListener = safeGet(host, "addEventListener");
|
||||
const removeEventListener = safeGet(host, "removeEventListener");
|
||||
if (
|
||||
typeof addEventListener !== "function" ||
|
||||
typeof removeEventListener !== "function"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const bindings = new Map<
|
||||
StoragePulseListener,
|
||||
(event: unknown) => void
|
||||
>();
|
||||
return Object.freeze({
|
||||
addEventListener(_type: "storage", listener: StoragePulseListener) {
|
||||
const bound = (event: unknown) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
listener({ key: null, newValue: null });
|
||||
return;
|
||||
}
|
||||
const record = event as Record<string, unknown>;
|
||||
const key = safeGet(record, "key");
|
||||
const newValue = safeGet(record, "newValue");
|
||||
listener({
|
||||
key: typeof key === "string" ? key : null,
|
||||
newValue: typeof newValue === "string" ? newValue : null,
|
||||
});
|
||||
};
|
||||
bindings.set(listener, bound);
|
||||
Reflect.apply(addEventListener, host, ["storage", bound]);
|
||||
},
|
||||
removeEventListener(
|
||||
_type: "storage",
|
||||
listener: StoragePulseListener,
|
||||
) {
|
||||
const bound = bindings.get(listener);
|
||||
if (!bound) return;
|
||||
bindings.delete(listener);
|
||||
Reflect.apply(removeEventListener, host, ["storage", bound]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
import {
|
||||
CACHE_INVALIDATION_PROTOCOL_VERSION,
|
||||
CACHE_INVALIDATION_WIRE_LIMITS,
|
||||
decodeCacheInvalidationWireEvent,
|
||||
isCacheInvalidationOpaqueIdentifier,
|
||||
isCacheInvalidationTopic,
|
||||
parseCacheInvalidationWireEvent,
|
||||
type CacheInvalidationParseFailureReason,
|
||||
type CacheInvalidationTopicDefinition,
|
||||
type CacheInvalidationWireEvent,
|
||||
} from "../../contracts/cache-invalidation.ts";
|
||||
|
||||
export type CrossContextInvalidationStatus =
|
||||
| "ACTIVE_BROADCAST"
|
||||
| "ACTIVE_STORAGE_FALLBACK"
|
||||
| "DEGRADED_LOCAL_ONLY"
|
||||
| "CLOSED";
|
||||
|
||||
export type CrossContextInvalidationTransport =
|
||||
| "BROADCAST"
|
||||
| "STORAGE"
|
||||
| "NONE";
|
||||
|
||||
export type CrossContextInvalidationOrdering = "NEXT" | "GAP";
|
||||
|
||||
export type CrossContextInvalidationDelivery = Readonly<{
|
||||
event: CacheInvalidationWireEvent;
|
||||
ordering: CrossContextInvalidationOrdering;
|
||||
transport: Exclude<CrossContextInvalidationTransport, "NONE">;
|
||||
}>;
|
||||
|
||||
export type CrossContextInvalidationObservationReason =
|
||||
| CacheInvalidationParseFailureReason
|
||||
| "BROADCAST_OPEN_FAILED"
|
||||
| "BROADCAST_PUBLISH_FAILED"
|
||||
| "CLOSED"
|
||||
| "DELIVERED"
|
||||
| "DUPLICATE"
|
||||
| "HANDLER_FAILED"
|
||||
| "OPENED"
|
||||
| "PUBLISHED"
|
||||
| "SELF_ECHO"
|
||||
| "SEQUENCE_EXHAUSTED"
|
||||
| "STALE"
|
||||
| "STORAGE_CLEANUP_FAILED"
|
||||
| "STORAGE_LISTENER_FAILED"
|
||||
| "STORAGE_PUBLISH_FAILED";
|
||||
|
||||
/**
|
||||
* Safe to project into diagnostics: it contains no event, topic, cache epoch,
|
||||
* source identifier, storage value or native exception.
|
||||
*/
|
||||
export type CrossContextInvalidationObservation = Readonly<{
|
||||
operation: "OPEN" | "PUBLISH" | "RECEIVE" | "CLOSE";
|
||||
outcome: "ACCEPTED" | "DEGRADED" | "DROPPED" | "FAILED";
|
||||
transport: CrossContextInvalidationTransport;
|
||||
reason: CrossContextInvalidationObservationReason;
|
||||
ordering?: CrossContextInvalidationOrdering;
|
||||
}>;
|
||||
|
||||
export type CrossContextInvalidationPublishResult =
|
||||
| Readonly<{
|
||||
ok: true;
|
||||
transport: Exclude<CrossContextInvalidationTransport, "NONE">;
|
||||
}>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
reason:
|
||||
| "CLOSED"
|
||||
| "INVALID_EVENT"
|
||||
| "SEQUENCE_EXHAUSTED"
|
||||
| "TRANSPORT_UNAVAILABLE";
|
||||
}>;
|
||||
|
||||
export type BroadcastMessageEventFacade = Readonly<{ data: unknown }>;
|
||||
export type BroadcastMessageListener = (
|
||||
event: BroadcastMessageEventFacade,
|
||||
) => void;
|
||||
|
||||
export type BroadcastChannelFacade = Readonly<{
|
||||
postMessage(value: unknown): void;
|
||||
addEventListener(
|
||||
type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
): void;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
export type StoragePulseFacade = Readonly<{
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}>;
|
||||
|
||||
export type StoragePulseEvent = Readonly<{
|
||||
key: string | null;
|
||||
newValue: string | null;
|
||||
}>;
|
||||
|
||||
export type StoragePulseListener = (event: StoragePulseEvent) => void;
|
||||
|
||||
export type StorageEventTargetFacade = Readonly<{
|
||||
addEventListener(type: "storage", listener: StoragePulseListener): void;
|
||||
removeEventListener(type: "storage", listener: StoragePulseListener): void;
|
||||
}>;
|
||||
|
||||
export type BrowserCrossContextInvalidationDependencies = Readonly<{
|
||||
channelName: string;
|
||||
storagePulseKey: string;
|
||||
sourceId: string;
|
||||
sourceEpoch: string;
|
||||
cacheEpoch: string;
|
||||
topics: readonly CacheInvalidationTopicDefinition[];
|
||||
createEventId(): string;
|
||||
createBroadcastChannel?: (name: string) => BroadcastChannelFacade;
|
||||
storage?: StoragePulseFacade;
|
||||
storageEvents?: StorageEventTargetFacade;
|
||||
nowEpochMilliseconds?: () => number;
|
||||
eventTtlMs?: number;
|
||||
dedupeCapacity?: number;
|
||||
sourceCapacity?: number;
|
||||
observe?: (observation: CrossContextInvalidationObservation) => void;
|
||||
}>;
|
||||
|
||||
export type BrowserCrossContextInvalidation = Readonly<{
|
||||
getStatus(): CrossContextInvalidationStatus;
|
||||
publish(input: {
|
||||
topic: string;
|
||||
topicVersion: number;
|
||||
}): CrossContextInvalidationPublishResult;
|
||||
subscribe(
|
||||
listener: (delivery: CrossContextInvalidationDelivery) => void,
|
||||
): () => void;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
type SeenEvent = Readonly<{ expiresAt: number }>;
|
||||
type SourceHighWatermark = Readonly<{
|
||||
sequence: number;
|
||||
expiresAt: number;
|
||||
}>;
|
||||
|
||||
const DEFAULT_EVENT_TTL_MS = 60_000;
|
||||
const DEFAULT_DEDUPE_CAPACITY = 1_024;
|
||||
const DEFAULT_SOURCE_CAPACITY = 256;
|
||||
const MAX_DEDUPE_CAPACITY = 4_096;
|
||||
const MAX_SOURCE_CAPACITY = 1_024;
|
||||
const MAX_CHANNEL_NAME_LENGTH = 128;
|
||||
const MAX_STORAGE_KEY_LENGTH = 256;
|
||||
|
||||
export function createBrowserCrossContextInvalidation(
|
||||
dependencies: BrowserCrossContextInvalidationDependencies,
|
||||
): BrowserCrossContextInvalidation {
|
||||
const topicVersions = validateConfiguration(dependencies);
|
||||
const now = dependencies.nowEpochMilliseconds ?? Date.now;
|
||||
const eventTtlMs = dependencies.eventTtlMs ?? DEFAULT_EVENT_TTL_MS;
|
||||
const dedupeCapacity =
|
||||
dependencies.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY;
|
||||
const sourceCapacity =
|
||||
dependencies.sourceCapacity ?? DEFAULT_SOURCE_CAPACITY;
|
||||
const listeners = new Set<
|
||||
(delivery: CrossContextInvalidationDelivery) => void
|
||||
>();
|
||||
const seenEvents = new Map<string, SeenEvent>();
|
||||
const sourceHighWatermarks = new Map<string, SourceHighWatermark>();
|
||||
|
||||
let closed = false;
|
||||
let sequence = 0;
|
||||
let broadcast: BroadcastChannelFacade | undefined;
|
||||
let storageListenerInstalled = false;
|
||||
let status: CrossContextInvalidationStatus = "DEGRADED_LOCAL_ONLY";
|
||||
|
||||
const receiveBroadcast: BroadcastMessageListener = (message) => {
|
||||
receive(message.data, "BROADCAST");
|
||||
};
|
||||
const receiveStorage: StoragePulseListener = (event) => {
|
||||
if (
|
||||
closed ||
|
||||
event.key !== dependencies.storagePulseKey ||
|
||||
typeof event.newValue !== "string"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parsed = decodeCacheInvalidationWireEvent(event.newValue, {
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topicVersions,
|
||||
nowEpochMilliseconds: safeNow(now),
|
||||
});
|
||||
acceptParsed(parsed, "STORAGE");
|
||||
};
|
||||
|
||||
installStorageListener();
|
||||
openBroadcast();
|
||||
refreshStatus();
|
||||
|
||||
function installStorageListener(): void {
|
||||
if (!dependencies.storageEvents) return;
|
||||
try {
|
||||
dependencies.storageEvents.addEventListener(
|
||||
"storage",
|
||||
receiveStorage,
|
||||
);
|
||||
storageListenerInstalled = true;
|
||||
} catch {
|
||||
observe({
|
||||
operation: "OPEN",
|
||||
outcome: "DEGRADED",
|
||||
transport: "STORAGE",
|
||||
reason: "STORAGE_LISTENER_FAILED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openBroadcast(): void {
|
||||
if (!dependencies.createBroadcastChannel) return;
|
||||
let candidate: BroadcastChannelFacade | undefined;
|
||||
try {
|
||||
candidate = dependencies.createBroadcastChannel(
|
||||
dependencies.channelName,
|
||||
);
|
||||
candidate.addEventListener("message", receiveBroadcast);
|
||||
broadcast = candidate;
|
||||
observe({
|
||||
operation: "OPEN",
|
||||
outcome: "ACCEPTED",
|
||||
transport: "BROADCAST",
|
||||
reason: "OPENED",
|
||||
});
|
||||
} catch {
|
||||
if (candidate) {
|
||||
try {
|
||||
candidate.removeEventListener("message", receiveBroadcast);
|
||||
} catch {
|
||||
// Opening still fails closed when listener cleanup is rejected.
|
||||
}
|
||||
try {
|
||||
candidate.close();
|
||||
} catch {
|
||||
// Opening still falls back when provider cleanup is rejected.
|
||||
}
|
||||
}
|
||||
observe({
|
||||
operation: "OPEN",
|
||||
outcome: "DEGRADED",
|
||||
transport: "BROADCAST",
|
||||
reason: "BROADCAST_OPEN_FAILED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStatus(): void {
|
||||
if (closed) {
|
||||
status = "CLOSED";
|
||||
} else if (broadcast) {
|
||||
status = "ACTIVE_BROADCAST";
|
||||
} else if (
|
||||
dependencies.storage &&
|
||||
dependencies.storageEvents &&
|
||||
storageListenerInstalled
|
||||
) {
|
||||
status = "ACTIVE_STORAGE_FALLBACK";
|
||||
} else {
|
||||
status = "DEGRADED_LOCAL_ONLY";
|
||||
}
|
||||
}
|
||||
|
||||
function publish(input: {
|
||||
topic: string;
|
||||
topicVersion: number;
|
||||
}): CrossContextInvalidationPublishResult {
|
||||
if (closed) {
|
||||
return Object.freeze({ ok: false, reason: "CLOSED" });
|
||||
}
|
||||
if (sequence >= Number.MAX_SAFE_INTEGER) {
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "FAILED",
|
||||
transport: "NONE",
|
||||
reason: "SEQUENCE_EXHAUSTED",
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: "SEQUENCE_EXHAUSTED",
|
||||
});
|
||||
}
|
||||
|
||||
const emittedAt = safeNow(now);
|
||||
let eventId: string;
|
||||
try {
|
||||
eventId = dependencies.createEventId();
|
||||
} catch {
|
||||
return invalidPublish();
|
||||
}
|
||||
const candidate: CacheInvalidationWireEvent = Object.freeze({
|
||||
protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION,
|
||||
eventId,
|
||||
sourceId: dependencies.sourceId,
|
||||
sourceEpoch: dependencies.sourceEpoch,
|
||||
sequence: sequence + 1,
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topic: input.topic,
|
||||
topicVersion: input.topicVersion,
|
||||
emittedAt,
|
||||
expiresAt: emittedAt + eventTtlMs,
|
||||
});
|
||||
const parsed = parseCacheInvalidationWireEvent(candidate, {
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topicVersions,
|
||||
nowEpochMilliseconds: emittedAt,
|
||||
});
|
||||
if (!parsed.ok) return invalidPublish();
|
||||
|
||||
sequence = candidate.sequence;
|
||||
pruneTracking(emittedAt);
|
||||
if (broadcast) {
|
||||
try {
|
||||
broadcast.postMessage(candidate);
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "ACCEPTED",
|
||||
transport: "BROADCAST",
|
||||
reason: "PUBLISHED",
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
transport: "BROADCAST",
|
||||
});
|
||||
} catch {
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "BROADCAST",
|
||||
reason: "BROADCAST_PUBLISH_FAILED",
|
||||
});
|
||||
closeBroadcast();
|
||||
refreshStatus();
|
||||
}
|
||||
}
|
||||
return publishThroughStorage(candidate);
|
||||
}
|
||||
|
||||
function invalidPublish(): CrossContextInvalidationPublishResult {
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "FAILED",
|
||||
transport: "NONE",
|
||||
reason: "INVALID_ENVELOPE",
|
||||
});
|
||||
return Object.freeze({ ok: false, reason: "INVALID_EVENT" });
|
||||
}
|
||||
|
||||
function publishThroughStorage(
|
||||
event: CacheInvalidationWireEvent,
|
||||
): CrossContextInvalidationPublishResult {
|
||||
if (
|
||||
!dependencies.storage ||
|
||||
!dependencies.storageEvents ||
|
||||
!storageListenerInstalled
|
||||
) {
|
||||
status = "DEGRADED_LOCAL_ONLY";
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "NONE",
|
||||
reason: "STORAGE_PUBLISH_FAILED",
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: "TRANSPORT_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(event);
|
||||
try {
|
||||
dependencies.storage.setItem(
|
||||
dependencies.storagePulseKey,
|
||||
serialized,
|
||||
);
|
||||
} catch {
|
||||
status = "DEGRADED_LOCAL_ONLY";
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "STORAGE",
|
||||
reason: "STORAGE_PUBLISH_FAILED",
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: "TRANSPORT_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
dependencies.storage.removeItem(dependencies.storagePulseKey);
|
||||
} catch {
|
||||
// A fixed pulse key prevents unbounded retained keys. A later publish
|
||||
// overwrites it with a unique event, so delivery succeeded even when
|
||||
// best-effort cleanup did not.
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "STORAGE",
|
||||
reason: "STORAGE_CLEANUP_FAILED",
|
||||
});
|
||||
}
|
||||
status = "ACTIVE_STORAGE_FALLBACK";
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "ACCEPTED",
|
||||
transport: "STORAGE",
|
||||
reason: "PUBLISHED",
|
||||
});
|
||||
return Object.freeze({ ok: true, transport: "STORAGE" });
|
||||
}
|
||||
|
||||
function receive(
|
||||
input: unknown,
|
||||
transport: Exclude<CrossContextInvalidationTransport, "NONE">,
|
||||
): void {
|
||||
if (closed) return;
|
||||
const parsed = parseCacheInvalidationWireEvent(input, {
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topicVersions,
|
||||
nowEpochMilliseconds: safeNow(now),
|
||||
});
|
||||
acceptParsed(parsed, transport);
|
||||
}
|
||||
|
||||
function acceptParsed(
|
||||
parsed: ReturnType<typeof parseCacheInvalidationWireEvent>,
|
||||
transport: Exclude<CrossContextInvalidationTransport, "NONE">,
|
||||
): void {
|
||||
if (closed) return;
|
||||
if (!parsed.ok) {
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport,
|
||||
reason: parsed.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const event = parsed.value;
|
||||
if (
|
||||
event.sourceId === dependencies.sourceId &&
|
||||
event.sourceEpoch === dependencies.sourceEpoch
|
||||
) {
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport,
|
||||
reason: "SELF_ECHO",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = safeNow(now);
|
||||
pruneTracking(currentTime);
|
||||
if (seenEvents.has(event.eventId)) {
|
||||
touchSeen(event.eventId, event.expiresAt);
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport,
|
||||
reason: "DUPLICATE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
touchSeen(event.eventId, event.expiresAt);
|
||||
|
||||
const sourceKey = `${event.sourceId}\u0000${event.sourceEpoch}`;
|
||||
const previous = sourceHighWatermarks.get(sourceKey);
|
||||
if (previous && event.sequence <= previous.sequence) {
|
||||
touchSource(sourceKey, previous);
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport,
|
||||
reason: "STALE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ordering: CrossContextInvalidationOrdering =
|
||||
(!previous && event.sequence > 1) ||
|
||||
(previous !== undefined &&
|
||||
event.sequence > previous.sequence + 1)
|
||||
? "GAP"
|
||||
: "NEXT";
|
||||
touchSource(sourceKey, {
|
||||
sequence: event.sequence,
|
||||
expiresAt: event.expiresAt,
|
||||
});
|
||||
|
||||
const delivery = Object.freeze({ event, ordering, transport });
|
||||
for (const listener of [...listeners]) {
|
||||
if (closed) return;
|
||||
if (!listeners.has(listener)) continue;
|
||||
try {
|
||||
listener(delivery);
|
||||
} catch {
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "FAILED",
|
||||
transport,
|
||||
reason: "HANDLER_FAILED",
|
||||
ordering,
|
||||
});
|
||||
}
|
||||
}
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "ACCEPTED",
|
||||
transport,
|
||||
reason: "DELIVERED",
|
||||
ordering,
|
||||
});
|
||||
}
|
||||
|
||||
function touchSeen(eventId: string, expiresAt: number): void {
|
||||
seenEvents.delete(eventId);
|
||||
seenEvents.set(eventId, { expiresAt });
|
||||
evictOldest(seenEvents, dedupeCapacity);
|
||||
}
|
||||
|
||||
function touchSource(
|
||||
sourceKey: string,
|
||||
value: SourceHighWatermark,
|
||||
): void {
|
||||
sourceHighWatermarks.delete(sourceKey);
|
||||
sourceHighWatermarks.set(sourceKey, value);
|
||||
evictOldest(sourceHighWatermarks, sourceCapacity);
|
||||
}
|
||||
|
||||
function pruneTracking(currentTime: number): void {
|
||||
for (const [eventId, entry] of seenEvents) {
|
||||
if (entry.expiresAt <= currentTime) seenEvents.delete(eventId);
|
||||
}
|
||||
for (const [sourceKey, entry] of sourceHighWatermarks) {
|
||||
if (entry.expiresAt <= currentTime) {
|
||||
sourceHighWatermarks.delete(sourceKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
listener: (delivery: CrossContextInvalidationDelivery) => void,
|
||||
): () => void {
|
||||
if (closed) return () => {};
|
||||
listeners.add(listener);
|
||||
let subscribed = true;
|
||||
return () => {
|
||||
if (!subscribed) return;
|
||||
subscribed = false;
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function closeBroadcast(): void {
|
||||
const current = broadcast;
|
||||
broadcast = undefined;
|
||||
if (!current) return;
|
||||
try {
|
||||
current.removeEventListener("message", receiveBroadcast);
|
||||
} catch {
|
||||
// Closing continues even when a provider rejects listener removal.
|
||||
}
|
||||
try {
|
||||
current.close();
|
||||
} catch {
|
||||
// Closing is best-effort and remains idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
closeBroadcast();
|
||||
if (storageListenerInstalled && dependencies.storageEvents) {
|
||||
try {
|
||||
dependencies.storageEvents.removeEventListener(
|
||||
"storage",
|
||||
receiveStorage,
|
||||
);
|
||||
} catch {
|
||||
// Local closed state still prevents any late callback.
|
||||
}
|
||||
}
|
||||
storageListenerInstalled = false;
|
||||
listeners.clear();
|
||||
seenEvents.clear();
|
||||
sourceHighWatermarks.clear();
|
||||
status = "CLOSED";
|
||||
observe({
|
||||
operation: "CLOSE",
|
||||
outcome: "ACCEPTED",
|
||||
transport: "NONE",
|
||||
reason: "CLOSED",
|
||||
});
|
||||
}
|
||||
|
||||
function observe(
|
||||
observation: CrossContextInvalidationObservation,
|
||||
): void {
|
||||
try {
|
||||
dependencies.observe?.(Object.freeze({ ...observation }));
|
||||
} catch {
|
||||
// Transport behavior must not depend on diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
getStatus: () => status,
|
||||
publish,
|
||||
subscribe,
|
||||
close,
|
||||
});
|
||||
}
|
||||
|
||||
function validateConfiguration(
|
||||
dependencies: BrowserCrossContextInvalidationDependencies,
|
||||
): Readonly<Record<string, number>> {
|
||||
if (
|
||||
typeof dependencies.channelName !== "string" ||
|
||||
dependencies.channelName.length < 1 ||
|
||||
dependencies.channelName.length > MAX_CHANNEL_NAME_LENGTH ||
|
||||
typeof dependencies.storagePulseKey !== "string" ||
|
||||
dependencies.storagePulseKey.length < 1 ||
|
||||
dependencies.storagePulseKey.length > MAX_STORAGE_KEY_LENGTH ||
|
||||
!isCacheInvalidationOpaqueIdentifier(dependencies.sourceId) ||
|
||||
!isCacheInvalidationOpaqueIdentifier(dependencies.sourceEpoch) ||
|
||||
!isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch) ||
|
||||
typeof dependencies.createEventId !== "function"
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation configuration is invalid.",
|
||||
);
|
||||
}
|
||||
const eventTtlMs = dependencies.eventTtlMs ?? DEFAULT_EVENT_TTL_MS;
|
||||
const dedupeCapacity =
|
||||
dependencies.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY;
|
||||
const sourceCapacity =
|
||||
dependencies.sourceCapacity ?? DEFAULT_SOURCE_CAPACITY;
|
||||
if (
|
||||
!Number.isSafeInteger(eventTtlMs) ||
|
||||
eventTtlMs < 1 ||
|
||||
eventTtlMs >
|
||||
CACHE_INVALIDATION_WIRE_LIMITS.maxEventTtlMs ||
|
||||
!Number.isSafeInteger(dedupeCapacity) ||
|
||||
dedupeCapacity < 1 ||
|
||||
dedupeCapacity > MAX_DEDUPE_CAPACITY ||
|
||||
!Number.isSafeInteger(sourceCapacity) ||
|
||||
sourceCapacity < 1 ||
|
||||
sourceCapacity > MAX_SOURCE_CAPACITY
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation bounds are invalid.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(dependencies.topics)) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation topic registry is invalid.",
|
||||
);
|
||||
}
|
||||
const topicVersions: Record<string, number> = Object.create(null);
|
||||
for (const definition of dependencies.topics) {
|
||||
if (
|
||||
!definition ||
|
||||
typeof definition !== "object" ||
|
||||
!isCacheInvalidationTopic(definition.topic) ||
|
||||
!Number.isSafeInteger(definition.topicVersion) ||
|
||||
definition.topicVersion < 1 ||
|
||||
Object.hasOwn(topicVersions, definition.topic)
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation topic registry is invalid.",
|
||||
);
|
||||
}
|
||||
topicVersions[definition.topic] = definition.topicVersion;
|
||||
}
|
||||
if (Object.keys(topicVersions).length < 1) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation requires an allowlisted topic.",
|
||||
);
|
||||
}
|
||||
return Object.freeze(topicVersions);
|
||||
}
|
||||
|
||||
function safeNow(now: () => number): number {
|
||||
try {
|
||||
const value = now();
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : -1;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function evictOldest<Value>(
|
||||
values: Map<string, Value>,
|
||||
capacity: number,
|
||||
): void {
|
||||
while (values.size > capacity) {
|
||||
const oldest = values.keys().next().value;
|
||||
if (typeof oldest !== "string") return;
|
||||
values.delete(oldest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
createBrowserCrossContextInvalidation,
|
||||
type BroadcastChannelFacade,
|
||||
type BroadcastMessageEventFacade,
|
||||
type BroadcastMessageListener,
|
||||
type BrowserCrossContextInvalidation,
|
||||
type BrowserCrossContextInvalidationDependencies,
|
||||
type CrossContextInvalidationDelivery,
|
||||
type CrossContextInvalidationObservation,
|
||||
type CrossContextInvalidationObservationReason,
|
||||
type CrossContextInvalidationOrdering,
|
||||
type CrossContextInvalidationPublishResult,
|
||||
type CrossContextInvalidationStatus,
|
||||
type CrossContextInvalidationTransport,
|
||||
type StorageEventTargetFacade,
|
||||
type StoragePulseEvent,
|
||||
type StoragePulseFacade,
|
||||
type StoragePulseListener,
|
||||
} from "./browser-cross-context-invalidation.ts";
|
||||
export {
|
||||
createBrowserCrossContextInvalidationFromHost,
|
||||
type BrowserCrossContextHostDependencies,
|
||||
} from "./browser-cross-context-host.ts";
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import {
|
||||
projectDiagnosticRecord,
|
||||
safeErrorKind,
|
||||
type DiagnosticRecord,
|
||||
type DiagnosticRecordInput,
|
||||
} from "../../contracts/diagnostics.ts";
|
||||
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
|
||||
|
||||
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
|
||||
record() {},
|
||||
});
|
||||
|
||||
export function createDiagnosticsAdapter(
|
||||
options: Readonly<{
|
||||
maxEntries?: number;
|
||||
now?: () => number;
|
||||
sink?: (record: DiagnosticRecord) => void;
|
||||
}> = {},
|
||||
) {
|
||||
const maxEntries = Math.max(1, options.maxEntries ?? 100);
|
||||
const entries: DiagnosticRecord[] = [];
|
||||
const droppedReasons = new Map<string, number>();
|
||||
|
||||
function drop(reason: string) {
|
||||
droppedReasons.set(reason, (droppedReasons.get(reason) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function record(input: DiagnosticRecordInput) {
|
||||
try {
|
||||
const projected = projectDiagnosticRecord(input, options.now);
|
||||
if (!projected.success) {
|
||||
drop(projected.reason);
|
||||
return;
|
||||
}
|
||||
if (entries.length >= maxEntries) {
|
||||
entries.shift();
|
||||
drop("queue-full");
|
||||
}
|
||||
entries.push(projected.record);
|
||||
try {
|
||||
options.sink?.(projected.record);
|
||||
} catch {
|
||||
drop("sink-failure");
|
||||
}
|
||||
} catch {
|
||||
drop("serialization-failure");
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
record,
|
||||
entries: () => structuredClone(entries) as readonly DiagnosticRecord[],
|
||||
dropped: () => Object.freeze(Object.fromEntries(droppedReasons)),
|
||||
});
|
||||
}
|
||||
|
||||
type BootSafeContext = Readonly<{
|
||||
kind?: string;
|
||||
buildId?: string;
|
||||
configSchemaVersion?: string;
|
||||
supportReference?: string;
|
||||
}>;
|
||||
|
||||
let lastBootEvidence:
|
||||
| Readonly<{
|
||||
diagnostic: DiagnosticRecord | null;
|
||||
telemetry: Readonly<Record<string, unknown>> | null;
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
export function recordBootFailure(
|
||||
error: unknown,
|
||||
safe: BootSafeContext,
|
||||
now: () => number = Date.now,
|
||||
) {
|
||||
const errorKind =
|
||||
typeof safe.kind === "string" ? safe.kind : safeErrorKind(error);
|
||||
const attributes = {
|
||||
error_kind: errorKind,
|
||||
build_id: safe.buildId ?? "unknown",
|
||||
config_schema_version: safe.configSchemaVersion ?? "unknown",
|
||||
};
|
||||
const diagnostic = projectDiagnosticRecord(
|
||||
{
|
||||
level: "error",
|
||||
eventId: "app.boot.failed",
|
||||
context: attributes,
|
||||
},
|
||||
now,
|
||||
);
|
||||
const telemetry = projectTelemetryEvent("app.boot.failed", attributes, now);
|
||||
lastBootEvidence = Object.freeze({
|
||||
diagnostic: diagnostic.success ? diagnostic.record : null,
|
||||
telemetry: telemetry.success ? telemetry.event : null,
|
||||
});
|
||||
return lastBootEvidence;
|
||||
}
|
||||
|
||||
export function getLastBootEvidence() {
|
||||
return lastBootEvidence ? structuredClone(lastBootEvidence) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* §7.9–§7.10. Common bounded body reader.
|
||||
*
|
||||
* `bounded-json.ts` stays the V2 reader for already-migrated callers; this
|
||||
* module adds the byte-level primitives execution V3 needs: a raw bounded read,
|
||||
* a strict media-type check and the `NONE` one-byte probe.
|
||||
*/
|
||||
|
||||
export type BoundedBytesOutcome =
|
||||
| Readonly<{ ok: true; bytes: Uint8Array }>
|
||||
| Readonly<{ ok: false; code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE" }>;
|
||||
|
||||
export type BodyProbeOutcome =
|
||||
| Readonly<{ ok: true; present: boolean }>
|
||||
| Readonly<{ ok: false; code: "RESPONSE_STREAM_FAILURE" }>;
|
||||
|
||||
/** Essence match: `application/json` or any `*+json` subtype. */
|
||||
export function isJsonMediaType(headerValue: string | null): boolean {
|
||||
if (!headerValue) return false;
|
||||
const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
return essence === "application/json" || essence.endsWith("+json");
|
||||
}
|
||||
|
||||
export function declaredContentLength(response: Response): number | null {
|
||||
const raw = response.headers.get("content-length");
|
||||
if (raw === null) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
export async function readBoundedBytes(
|
||||
response: Response,
|
||||
maximumBytes: number,
|
||||
): Promise<BoundedBytesOutcome> {
|
||||
const declared = declaredContentLength(response);
|
||||
if (declared !== null && declared > maximumBytes) {
|
||||
await cancelBody(response);
|
||||
return failure("RESPONSE_TOO_LARGE");
|
||||
}
|
||||
if (!response.body) {
|
||||
return Object.freeze({ ok: true as const, bytes: new Uint8Array(0) });
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
if (!next.value) continue;
|
||||
total += next.value.byteLength;
|
||||
if (total > maximumBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return failure("RESPONSE_TOO_LARGE");
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} catch {
|
||||
await reader.cancel().catch(() => {});
|
||||
return failure("RESPONSE_STREAM_FAILURE");
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// A cancelled reader has already released its lock.
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return Object.freeze({ ok: true as const, bytes });
|
||||
}
|
||||
|
||||
/**
|
||||
* §7.10 `NONE`. A declared positive length is an immediate violation. Otherwise
|
||||
* at most one byte is probed: the descriptor does not permit an unexpected
|
||||
* body, so the runtime never drains an arbitrary amount to find out.
|
||||
*/
|
||||
export async function probeForbiddenBody(
|
||||
response: Response,
|
||||
): Promise<BodyProbeOutcome> {
|
||||
const declared = declaredContentLength(response);
|
||||
if (declared !== null && declared > 0) {
|
||||
await cancelBody(response);
|
||||
return Object.freeze({ ok: true as const, present: true });
|
||||
}
|
||||
if (!response.body) {
|
||||
return Object.freeze({ ok: true as const, present: false });
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (next.done || !next.value || next.value.byteLength === 0) {
|
||||
return Object.freeze({ ok: true as const, present: false });
|
||||
}
|
||||
await reader.cancel().catch(() => {});
|
||||
return Object.freeze({ ok: true as const, present: true });
|
||||
} catch {
|
||||
await reader.cancel().catch(() => {});
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "RESPONSE_STREAM_FAILURE" as const,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Already released by cancel().
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type DecodedJson =
|
||||
| Readonly<{ ok: true; value: unknown }>
|
||||
| Readonly<{ ok: false; code: "UTF8_INVALID" | "JSON_INVALID" }>;
|
||||
|
||||
export function decodeJsonBytes(bytes: Uint8Array): DecodedJson {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return Object.freeze({ ok: false as const, code: "UTF8_INVALID" as const });
|
||||
}
|
||||
try {
|
||||
return Object.freeze({ ok: true as const, value: JSON.parse(text) });
|
||||
} catch {
|
||||
return Object.freeze({ ok: false as const, code: "JSON_INVALID" as const });
|
||||
}
|
||||
}
|
||||
|
||||
export function isEffectivelyEmpty(bytes: Uint8Array): boolean {
|
||||
if (bytes.byteLength === 0) return true;
|
||||
for (const byte of bytes) {
|
||||
// Space, tab, LF, CR are the only permitted "empty" filler.
|
||||
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function cancelBody(response: Response): Promise<void> {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// Cancelling an already-settled body is not itself a failure.
|
||||
}
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE",
|
||||
): BoundedBytesOutcome {
|
||||
return Object.freeze({ ok: false as const, code });
|
||||
}
|
||||
@@ -0,0 +1,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" };
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
HTTP_EXECUTION_CEILINGS,
|
||||
type InstalledHttpContract,
|
||||
} from "../../contracts/external-contract-runtime.ts";
|
||||
|
||||
/**
|
||||
* §7.4–§7.7. Descriptor-driven request projection.
|
||||
*
|
||||
* Nothing here re-derives operation semantics. The package descriptor supplies
|
||||
* path values, query entry order and the body value; this module only encodes,
|
||||
* bounds and re-verifies them.
|
||||
*/
|
||||
|
||||
export type CredentialPatchOutcome =
|
||||
| Readonly<{
|
||||
kind: "READY";
|
||||
headers: Readonly<Record<string, string>>;
|
||||
credentials: RequestCredentials;
|
||||
}>
|
||||
| Readonly<{ kind: "UNAUTHENTICATED" }>
|
||||
| Readonly<{ kind: "UNAVAILABLE" }>
|
||||
| Readonly<{ kind: "SCOPE_FENCED" }>;
|
||||
|
||||
/** §7.7. The complete set of headers a credential bridge may contribute. */
|
||||
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
"x-tenant-context",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
|
||||
"host",
|
||||
"origin",
|
||||
"referer",
|
||||
"cookie",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
export type RequestProjectionFailure =
|
||||
| "PROJECTION_RUNTIME_FAILURE"
|
||||
| "PROJECTION_INVALID"
|
||||
| "PATH_PLACEHOLDER_MISSING"
|
||||
| "PATH_VALUE_INVALID"
|
||||
| "QUERY_TOO_LARGE"
|
||||
| "URL_TOO_LARGE"
|
||||
| "URL_ORIGIN_ESCAPED"
|
||||
| "REQUEST_BODY_TOO_LARGE"
|
||||
| "REQUEST_BODY_UNENCODABLE"
|
||||
| "REQUEST_BODY_UNEXPECTED";
|
||||
|
||||
export type ProjectedRequest = Readonly<{
|
||||
url: string;
|
||||
method: string;
|
||||
bodyBytes: Uint8Array | null;
|
||||
}>;
|
||||
|
||||
export type RequestProjectionOutcome =
|
||||
| Readonly<{ ok: true; request: ProjectedRequest }>
|
||||
| Readonly<{ ok: false; failure: RequestProjectionFailure }>;
|
||||
|
||||
const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9_]*)\}|:([A-Za-z][A-Za-z0-9_]*)/g;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* §7.5. Built from the validated base URL and the descriptor's named values.
|
||||
* Segments are encoded exactly once; a raw slash inside a value is rejected
|
||||
* rather than silently creating a new path segment.
|
||||
*/
|
||||
export function projectRequest<Input, WireOutput, Problem>(
|
||||
installed: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||
input: Input,
|
||||
baseUrl: string,
|
||||
): RequestProjectionOutcome {
|
||||
try {
|
||||
return projectRequestChecked(installed, input, baseUrl);
|
||||
} catch {
|
||||
return failure("PROJECTION_RUNTIME_FAILURE");
|
||||
}
|
||||
}
|
||||
|
||||
function projectRequestChecked<Input, WireOutput, Problem>(
|
||||
installed: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||
input: Input,
|
||||
baseUrl: string,
|
||||
): RequestProjectionOutcome {
|
||||
const contract = installed.contract;
|
||||
const projection = contract.projectRequest(input);
|
||||
if (!isValidProjection(projection)) return failure("PROJECTION_INVALID");
|
||||
|
||||
let missing = false;
|
||||
let invalid = false;
|
||||
const path = contract.pathTemplate.replace(
|
||||
PLACEHOLDER,
|
||||
(_match, braced?: string, colon?: string) => {
|
||||
const name = braced ?? colon ?? "";
|
||||
const value = projection.pathValues[name];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
missing = true;
|
||||
return "";
|
||||
}
|
||||
if (value.includes("/") || value.includes("\\")) {
|
||||
invalid = true;
|
||||
return "";
|
||||
}
|
||||
return encodeURIComponent(value);
|
||||
},
|
||||
);
|
||||
if (missing) return failure("PATH_PLACEHOLDER_MISSING");
|
||||
if (invalid) return failure("PATH_VALUE_INVALID");
|
||||
|
||||
const base = new URL(baseUrl);
|
||||
const url = new URL(path.replace(/^\/+/, ""), base);
|
||||
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
|
||||
return failure("URL_ORIGIN_ESCAPED");
|
||||
}
|
||||
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of projection.queryEntries) {
|
||||
search.append(key, value);
|
||||
}
|
||||
const encodedQuery = search.toString();
|
||||
if (
|
||||
encoder.encode(encodedQuery).byteLength >
|
||||
HTTP_EXECUTION_CEILINGS.encodedQueryBytes
|
||||
) {
|
||||
return failure("QUERY_TOO_LARGE");
|
||||
}
|
||||
url.search = encodedQuery;
|
||||
|
||||
if (encoder.encode(url.href).byteLength > HTTP_EXECUTION_CEILINGS.finalUrlBytes) {
|
||||
return failure("URL_TOO_LARGE");
|
||||
}
|
||||
|
||||
let bodyBytes: Uint8Array | null = null;
|
||||
if (contract.requestBody === "JSON") {
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = JSON.stringify(projection.body);
|
||||
} catch {
|
||||
return failure("REQUEST_BODY_UNENCODABLE");
|
||||
}
|
||||
if (typeof encoded !== "string") {
|
||||
return failure("REQUEST_BODY_UNENCODABLE");
|
||||
}
|
||||
bodyBytes = encoder.encode(encoded);
|
||||
if (bodyBytes.byteLength > installed.frontend.requestByteLimit) {
|
||||
return failure("REQUEST_BODY_TOO_LARGE");
|
||||
}
|
||||
} else if (projection.body !== null && projection.body !== undefined) {
|
||||
return failure("REQUEST_BODY_UNEXPECTED");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
request: Object.freeze({
|
||||
url: url.href,
|
||||
method: contract.method,
|
||||
bodyBytes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function isValidProjection(
|
||||
value: unknown,
|
||||
): value is ReturnType<
|
||||
InstalledHttpContract<unknown, unknown, unknown>["contract"]["projectRequest"]
|
||||
> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const keys = Object.keys(candidate).sort();
|
||||
if (keys.join("|") !== "body|pathValues|queryEntries") return false;
|
||||
|
||||
const pathValues = candidate.pathValues;
|
||||
if (!pathValues || typeof pathValues !== "object" || Array.isArray(pathValues)) {
|
||||
return false;
|
||||
}
|
||||
const pathPrototype = Object.getPrototypeOf(pathValues);
|
||||
if (pathPrototype !== Object.prototype && pathPrototype !== null) return false;
|
||||
const pathEntries = Object.entries(pathValues as Record<string, unknown>);
|
||||
if (pathEntries.length > 32) return false;
|
||||
for (const [key, pathValue] of pathEntries) {
|
||||
if (
|
||||
!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) ||
|
||||
typeof pathValue !== "string" ||
|
||||
pathValue.length === 0 ||
|
||||
encoder.encode(pathValue).byteLength > HTTP_EXECUTION_CEILINGS.pathTemplateBytes
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const queryEntries = candidate.queryEntries;
|
||||
if (!Array.isArray(queryEntries) || queryEntries.length > 256) return false;
|
||||
for (const entry of queryEntries) {
|
||||
if (
|
||||
!Array.isArray(entry) ||
|
||||
entry.length !== 2 ||
|
||||
typeof entry[0] !== "string" ||
|
||||
typeof entry[1] !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type FinalInvariantInput = Readonly<{
|
||||
request: ProjectedRequest;
|
||||
expectedMethod: string;
|
||||
baseUrl: string;
|
||||
init: RequestInit;
|
||||
headers: Readonly<Record<string, string>>;
|
||||
requestByteLimit: number;
|
||||
deadlineRemainingMs: number;
|
||||
scopeIsCurrent: boolean;
|
||||
}>;
|
||||
|
||||
export type FinalInvariantFailure =
|
||||
| "METHOD_CHANGED"
|
||||
| "URL_NOT_ALLOWED"
|
||||
| "REDIRECT_MODE_INVALID"
|
||||
| "CREDENTIALS_MODE_INVALID"
|
||||
| "HEADER_NOT_ALLOWED"
|
||||
| "FORBIDDEN_HEADER"
|
||||
| "REQUEST_BODY_TOO_LARGE"
|
||||
| "DEADLINE_EXPIRED"
|
||||
| "SCOPE_FENCED";
|
||||
|
||||
/**
|
||||
* §7.4. Runs after the credential patch and immediately before dispatch. A
|
||||
* failure here means `fetch()` is called zero times, so a defective auth
|
||||
* adapter can never alter the method, target or transport policy.
|
||||
*/
|
||||
export function checkFinalInvariants(
|
||||
input: FinalInvariantInput,
|
||||
): FinalInvariantFailure | null {
|
||||
if (input.request.method !== input.expectedMethod) return "METHOD_CHANGED";
|
||||
|
||||
const base = new URL(input.baseUrl);
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(input.request.url);
|
||||
} catch {
|
||||
return "URL_NOT_ALLOWED";
|
||||
}
|
||||
if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname)) {
|
||||
return "URL_NOT_ALLOWED";
|
||||
}
|
||||
if (input.init.redirect !== "error") return "REDIRECT_MODE_INVALID";
|
||||
if (
|
||||
input.init.credentials !== "omit" &&
|
||||
input.init.credentials !== "same-origin" &&
|
||||
input.init.credentials !== "include"
|
||||
) {
|
||||
return "CREDENTIALS_MODE_INVALID";
|
||||
}
|
||||
|
||||
for (const name of Object.keys(input.headers)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
|
||||
if (
|
||||
lower !== "accept" &&
|
||||
lower !== "content-type" &&
|
||||
lower !== "idempotency-key" &&
|
||||
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
|
||||
) {
|
||||
return "HEADER_NOT_ALLOWED";
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.request.bodyBytes &&
|
||||
input.request.bodyBytes.byteLength > input.requestByteLimit
|
||||
) {
|
||||
return "REQUEST_BODY_TOO_LARGE";
|
||||
}
|
||||
if (input.deadlineRemainingMs <= 0) return "DEADLINE_EXPIRED";
|
||||
if (!input.scopeIsCurrent) return "SCOPE_FENCED";
|
||||
return null;
|
||||
}
|
||||
|
||||
function failure(failureKind: RequestProjectionFailure): RequestProjectionOutcome {
|
||||
return Object.freeze({ ok: false as const, failure: failureKind });
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
CommandEffectDescriptor,
|
||||
CommandEffectClassification,
|
||||
} from "../../contracts/external-contract-runtime.ts";
|
||||
|
||||
/**
|
||||
* §8.7–§8.9. Mutation effect certainty.
|
||||
*
|
||||
* The frontend never infers "not applied" from an HTTP status alone. Anything
|
||||
* observed after the request was dispatched but before a classified terminal
|
||||
* response is `MAYBE_APPLIED`, which forbids automatic resend.
|
||||
*/
|
||||
|
||||
export type MutationEffectCertainty =
|
||||
| "NOT_STARTED"
|
||||
| "NOT_APPLIED"
|
||||
| "MAYBE_APPLIED"
|
||||
| "APPLIED_CONFIRMED";
|
||||
|
||||
export type PhysicalAttemptState =
|
||||
| "NOT_STARTED"
|
||||
| "PREPARING"
|
||||
| "READY_TO_SEND"
|
||||
| "DISPATCHED"
|
||||
| "RESPONSE_HEADERS"
|
||||
| "READING_BODY"
|
||||
| "VALIDATING"
|
||||
| "MAPPING_READY"
|
||||
| "SETTLED";
|
||||
|
||||
/**
|
||||
* `READY_TO_SEND` is recorded immediately before entering the `fetch()`
|
||||
* invocation expression and `DISPATCHED` immediately after the promise is
|
||||
* returned. A synchronous throw therefore leaves the attempt `NOT_STARTED`.
|
||||
*/
|
||||
export function certaintyForAbandonedAttempt(
|
||||
state: PhysicalAttemptState,
|
||||
isCommand: boolean,
|
||||
): MutationEffectCertainty {
|
||||
if (!isCommand) return "NOT_STARTED";
|
||||
switch (state) {
|
||||
case "NOT_STARTED":
|
||||
case "PREPARING":
|
||||
case "READY_TO_SEND":
|
||||
return "NOT_STARTED";
|
||||
default:
|
||||
return "MAYBE_APPLIED";
|
||||
}
|
||||
}
|
||||
|
||||
export type ProblemEffectInput<Problem> = Readonly<{
|
||||
status: number;
|
||||
problem: Problem;
|
||||
descriptor: CommandEffectDescriptor<Problem> | null;
|
||||
}>;
|
||||
|
||||
export type ProblemEffectOutcome = Readonly<{
|
||||
effect: MutationEffectCertainty;
|
||||
contractRuntimeFailure: boolean;
|
||||
}>;
|
||||
|
||||
const CLASSIFICATIONS: ReadonlySet<CommandEffectClassification> = new Set([
|
||||
"NOT_APPLIED",
|
||||
"APPLIED_CONFIRMED",
|
||||
"MAYBE_APPLIED",
|
||||
]);
|
||||
|
||||
/**
|
||||
* §4.4. The classifier is package-owned and pure. A throw or an unrecognised
|
||||
* return value fails safe to `MAYBE_APPLIED` and is recorded as a contract
|
||||
* runtime failure rather than being silently treated as "not applied".
|
||||
*/
|
||||
export function classifyProblemEffect<Problem>(
|
||||
input: ProblemEffectInput<Problem>,
|
||||
): ProblemEffectOutcome {
|
||||
if (!input.descriptor) {
|
||||
// A read operation carries no command effect; there is nothing to apply.
|
||||
return Object.freeze({
|
||||
effect: "NOT_STARTED" as const,
|
||||
contractRuntimeFailure: false,
|
||||
});
|
||||
}
|
||||
let classification: CommandEffectClassification;
|
||||
try {
|
||||
classification = input.descriptor.classifyProblem({
|
||||
status: input.status,
|
||||
problem: input.problem,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
effect: "MAYBE_APPLIED" as const,
|
||||
contractRuntimeFailure: true,
|
||||
});
|
||||
}
|
||||
if (!CLASSIFICATIONS.has(classification)) {
|
||||
return Object.freeze({
|
||||
effect: "MAYBE_APPLIED" as const,
|
||||
contractRuntimeFailure: true,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
effect: classification,
|
||||
contractRuntimeFailure: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** §8.10. Certainty to UI intent. The copy itself is owned by the i18n catalog. */
|
||||
export function projectCertaintyToUi(
|
||||
certainty: MutationEffectCertainty,
|
||||
): "RETRYABLE" | "CHECK_STATUS" | "SUCCESS" {
|
||||
switch (certainty) {
|
||||
case "APPLIED_CONFIRMED":
|
||||
return "SUCCESS";
|
||||
case "MAYBE_APPLIED":
|
||||
return "CHECK_STATUS";
|
||||
default:
|
||||
return "RETRYABLE";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
import type { ApiOperation } from "../../contracts/api-operations.ts";
|
||||
|
||||
export type OperationRequestInput = Readonly<{
|
||||
operationId: string;
|
||||
routeId: string;
|
||||
pathParams?: Readonly<Record<string, string | number>>;
|
||||
searchParams?: unknown;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
idempotencyKey?: string;
|
||||
correlationId?: string;
|
||||
}>;
|
||||
|
||||
export type RequestTargetResult =
|
||||
| Readonly<{ success: true; url: URL }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
code:
|
||||
| "BASE_URL_INVALID"
|
||||
| "PATH_PARAMETER_MISSING"
|
||||
| "PATH_PARAMETER_UNEXPECTED"
|
||||
| "PATH_PARAMETER_INVALID"
|
||||
| "SEARCH_PARAMETER_INVALID";
|
||||
}>;
|
||||
|
||||
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
|
||||
|
||||
export function buildRequestTarget(
|
||||
baseUrl: string,
|
||||
operation: ApiOperation,
|
||||
pathParams: Readonly<Record<string, string | number>> = {},
|
||||
parsedSearch: unknown = {},
|
||||
): RequestTargetResult {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(baseUrl);
|
||||
} catch {
|
||||
return { success: false, code: "BASE_URL_INVALID" };
|
||||
}
|
||||
if (
|
||||
(base.protocol !== "https:" &&
|
||||
!(
|
||||
base.protocol === "http:" &&
|
||||
["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)
|
||||
)) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
base.search ||
|
||||
base.hash
|
||||
) {
|
||||
return { success: false, code: "BASE_URL_INVALID" };
|
||||
}
|
||||
|
||||
const placeholders = new Set<string>();
|
||||
for (const match of operation.path.matchAll(pathParameterPattern)) {
|
||||
placeholders.add(match[1] ?? match[2] ?? "");
|
||||
}
|
||||
if (Object.keys(pathParams).some((key) => !placeholders.has(key))) {
|
||||
return { success: false, code: "PATH_PARAMETER_UNEXPECTED" };
|
||||
}
|
||||
let missingPathParameter = false;
|
||||
let invalidPathParameter = false;
|
||||
const pathname = operation.path.replace(
|
||||
pathParameterPattern,
|
||||
(_token, colonName: string | undefined, braceName: string | undefined) => {
|
||||
const name = colonName ?? braceName ?? "";
|
||||
const value = pathParams[name];
|
||||
if (value === undefined) {
|
||||
missingPathParameter = true;
|
||||
return "";
|
||||
}
|
||||
const serialized = String(value);
|
||||
if (
|
||||
serialized.length === 0 ||
|
||||
serialized.length > 512 ||
|
||||
[...serialized].some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code < 32 || code === 127;
|
||||
})
|
||||
) {
|
||||
invalidPathParameter = true;
|
||||
return "";
|
||||
}
|
||||
return encodeURIComponent(serialized);
|
||||
},
|
||||
);
|
||||
if (missingPathParameter) {
|
||||
return { success: false, code: "PATH_PARAMETER_MISSING" };
|
||||
}
|
||||
if (invalidPathParameter) {
|
||||
return { success: false, code: "PATH_PARAMETER_INVALID" };
|
||||
}
|
||||
|
||||
if (
|
||||
parsedSearch === null ||
|
||||
typeof parsedSearch !== "object" ||
|
||||
Array.isArray(parsedSearch)
|
||||
) {
|
||||
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
|
||||
}
|
||||
|
||||
const basePrefix = base.pathname.endsWith("/")
|
||||
? base.pathname
|
||||
: `${base.pathname}/`;
|
||||
const relativePath = pathname.replace(/^\/+/, "");
|
||||
base.pathname = `${basePrefix}${relativePath}`.replace(/\/{2,}/g, "/");
|
||||
const url = base;
|
||||
const search = parsedSearch as Readonly<Record<string, unknown>>;
|
||||
for (const key of Object.keys(search).sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
const value = search[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const item of values) {
|
||||
if (
|
||||
typeof item !== "string" &&
|
||||
typeof item !== "number" &&
|
||||
typeof item !== "boolean"
|
||||
) {
|
||||
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
|
||||
}
|
||||
url.searchParams.append(key, String(item));
|
||||
}
|
||||
}
|
||||
if (
|
||||
operation.maxEncodedSearchBytes !== undefined &&
|
||||
new TextEncoder().encode(url.search).byteLength >
|
||||
operation.maxEncodedSearchBytes
|
||||
) {
|
||||
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
|
||||
}
|
||||
return { success: true, url };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const retryKinds: ReadonlySet<string> = new Set([
|
||||
"NETWORK_UNREACHABLE",
|
||||
"REQUEST_TIMEOUT",
|
||||
"RATE_LIMITED",
|
||||
"SERVER_FAILURE",
|
||||
]);
|
||||
|
||||
export function calculateBackoff(
|
||||
retryIndex: number,
|
||||
random = Math.random,
|
||||
baseDelayMs = 250,
|
||||
maxDelayMs = 2_000,
|
||||
): number {
|
||||
return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random();
|
||||
}
|
||||
|
||||
export function parseRetryAfter(
|
||||
value: string | null | undefined,
|
||||
now = Date.now(),
|
||||
): number | null {
|
||||
if (!value) return null;
|
||||
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds)) {
|
||||
return seconds < 0 ? null : seconds * 1_000;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
return Math.max(0, timestamp - now);
|
||||
}
|
||||
|
||||
export type RetryOperation = Readonly<{
|
||||
idempotency: "safe" | "keyed" | "none";
|
||||
retry?: "runtime" | "never";
|
||||
}>;
|
||||
|
||||
export type RetryFailure = Readonly<{
|
||||
kind: string;
|
||||
retryAfterMs?: number;
|
||||
retryAfter?: string;
|
||||
httpStatus?: number;
|
||||
}>;
|
||||
|
||||
export function shouldRetry(
|
||||
operation: RetryOperation,
|
||||
failure: RetryFailure,
|
||||
retryCount: number,
|
||||
maxRetries = 2,
|
||||
): boolean {
|
||||
if (operation.retry === "never") return false;
|
||||
if (retryCount >= maxRetries) return false;
|
||||
if (!retryKinds.has(failure.kind)) return false;
|
||||
if (
|
||||
failure.kind === "SERVER_FAILURE" &&
|
||||
failure.httpStatus !== undefined &&
|
||||
![502, 503, 504].includes(failure.httpStatus)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
failure.kind === "RATE_LIMITED" &&
|
||||
typeof failure.retryAfterMs === "number" &&
|
||||
failure.retryAfterMs > 30_000
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return operation.idempotency === "safe" || operation.idempotency === "keyed";
|
||||
}
|
||||
|
||||
export function retryDelay(
|
||||
failure: RetryFailure,
|
||||
retryIndex: number,
|
||||
random = Math.random,
|
||||
now = Date.now(),
|
||||
): number {
|
||||
const localBackoff = calculateBackoff(retryIndex, random);
|
||||
if (failure.kind !== "RATE_LIMITED") return localBackoff;
|
||||
|
||||
const retryAfterMs =
|
||||
typeof failure.retryAfterMs === "number"
|
||||
? failure.retryAfterMs
|
||||
: parseRetryAfter(failure.retryAfter, now);
|
||||
|
||||
return retryAfterMs === null ? localBackoff : Math.max(localBackoff, retryAfterMs);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* §20.4. The single owner of window lifecycle listeners.
|
||||
*
|
||||
* No capability adds its own `visibilitychange`, `online`, `offline`, `focus`,
|
||||
* `pagehide` or `pageshow` listener. They subscribe here instead, so the
|
||||
* listener count stays constant and leak inspection (§23.14) is meaningful.
|
||||
*
|
||||
* §20.6: nothing in this module is a correctness boundary. `beforeunload` is a
|
||||
* user prompt, never a place to complete a command, write a checkpoint or
|
||||
* guarantee a lease release.
|
||||
*/
|
||||
|
||||
export type BrowserLifecycleSnapshot = Readonly<{
|
||||
visibility: "VISIBLE" | "HIDDEN";
|
||||
connectivityHint: "ONLINE" | "OFFLINE";
|
||||
pageState: "ACTIVE" | "PAGEHIDE" | "BFCACHE_RESTORED";
|
||||
generation: number;
|
||||
}>;
|
||||
|
||||
export type BrowserLifecycleEvent =
|
||||
| Readonly<{ kind: "VISIBILITY_CHANGED"; snapshot: BrowserLifecycleSnapshot }>
|
||||
| Readonly<{ kind: "ONLINE"; snapshot: BrowserLifecycleSnapshot }>
|
||||
| Readonly<{ kind: "OFFLINE"; snapshot: BrowserLifecycleSnapshot }>
|
||||
| Readonly<{ kind: "FOCUS"; snapshot: BrowserLifecycleSnapshot }>
|
||||
| Readonly<{
|
||||
kind: "PAGEHIDE";
|
||||
persisted: boolean;
|
||||
snapshot: BrowserLifecycleSnapshot;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "PAGESHOW";
|
||||
persisted: boolean;
|
||||
snapshot: BrowserLifecycleSnapshot;
|
||||
}>;
|
||||
|
||||
export type BrowserLifecycleRuntime = Readonly<{
|
||||
getSnapshot(): BrowserLifecycleSnapshot;
|
||||
subscribe(listener: (event: BrowserLifecycleEvent) => void): () => void;
|
||||
/**
|
||||
* Registers a dirty-state source. `beforeunload` is attached only while at
|
||||
* least one source reports dirty, and it uses the browser's standard prompt.
|
||||
*/
|
||||
registerDirtySource(isDirty: () => boolean): () => void;
|
||||
dispose(): void;
|
||||
}>;
|
||||
|
||||
type LifecycleHost = Readonly<{
|
||||
addEventListener: Window["addEventListener"];
|
||||
removeEventListener: Window["removeEventListener"];
|
||||
document?: Pick<Document, "visibilityState"> & {
|
||||
addEventListener: Document["addEventListener"];
|
||||
removeEventListener: Document["removeEventListener"];
|
||||
};
|
||||
navigator?: Pick<Navigator, "onLine">;
|
||||
}>;
|
||||
|
||||
export function createBrowserLifecycleRuntime(
|
||||
host: LifecycleHost = globalThis as unknown as LifecycleHost,
|
||||
): BrowserLifecycleRuntime {
|
||||
const listeners = new Set<(event: BrowserLifecycleEvent) => void>();
|
||||
const dirtySources = new Set<() => boolean>();
|
||||
const document = host.document;
|
||||
|
||||
let generation = 1;
|
||||
let visibility: BrowserLifecycleSnapshot["visibility"] =
|
||||
document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE";
|
||||
let connectivityHint: BrowserLifecycleSnapshot["connectivityHint"] =
|
||||
host.navigator?.onLine === false ? "OFFLINE" : "ONLINE";
|
||||
let pageState: BrowserLifecycleSnapshot["pageState"] = "ACTIVE";
|
||||
let disposed = false;
|
||||
let beforeUnloadAttached = false;
|
||||
|
||||
function snapshot(): BrowserLifecycleSnapshot {
|
||||
return Object.freeze({
|
||||
visibility,
|
||||
connectivityHint,
|
||||
pageState,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
function publish(event: BrowserLifecycleEvent): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// One subscriber defect cannot suppress the signal for the others.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onVisibility = () => {
|
||||
visibility = document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE";
|
||||
publish({ kind: "VISIBILITY_CHANGED", snapshot: snapshot() });
|
||||
};
|
||||
const onOnline = () => {
|
||||
connectivityHint = "ONLINE";
|
||||
publish({ kind: "ONLINE", snapshot: snapshot() });
|
||||
};
|
||||
const onOffline = () => {
|
||||
connectivityHint = "OFFLINE";
|
||||
publish({ kind: "OFFLINE", snapshot: snapshot() });
|
||||
};
|
||||
const onFocus = () => {
|
||||
publish({ kind: "FOCUS", snapshot: snapshot() });
|
||||
};
|
||||
const onPageHide = (event: Event) => {
|
||||
const persisted = (event as PageTransitionEvent).persisted === true;
|
||||
pageState = "PAGEHIDE";
|
||||
publish({ kind: "PAGEHIDE", persisted, snapshot: snapshot() });
|
||||
};
|
||||
const onPageShow = (event: Event) => {
|
||||
const persisted = (event as PageTransitionEvent).persisted === true;
|
||||
// §20.5. A bfcache restore is a new lifecycle generation, not a fresh boot.
|
||||
if (persisted) generation += 1;
|
||||
pageState = persisted ? "BFCACHE_RESTORED" : "ACTIVE";
|
||||
publish({ kind: "PAGESHOW", persisted, snapshot: snapshot() });
|
||||
};
|
||||
const onBeforeUnload = (event: Event) => {
|
||||
if (!hasDirtyState()) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
function hasDirtyState(): boolean {
|
||||
for (const isDirty of dirtySources) {
|
||||
try {
|
||||
if (isDirty()) return true;
|
||||
} catch {
|
||||
// A defective reporter is treated as clean rather than trapping the user.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function syncBeforeUnload(): void {
|
||||
const shouldAttach = dirtySources.size > 0;
|
||||
if (shouldAttach && !beforeUnloadAttached) {
|
||||
host.addEventListener("beforeunload", onBeforeUnload);
|
||||
beforeUnloadAttached = true;
|
||||
} else if (!shouldAttach && beforeUnloadAttached) {
|
||||
host.removeEventListener("beforeunload", onBeforeUnload);
|
||||
beforeUnloadAttached = false;
|
||||
}
|
||||
}
|
||||
|
||||
document?.addEventListener("visibilitychange", onVisibility);
|
||||
host.addEventListener("online", onOnline);
|
||||
host.addEventListener("offline", onOffline);
|
||||
host.addEventListener("focus", onFocus);
|
||||
host.addEventListener("pagehide", onPageHide);
|
||||
host.addEventListener("pageshow", onPageShow);
|
||||
|
||||
return Object.freeze({
|
||||
getSnapshot: snapshot,
|
||||
subscribe(listener) {
|
||||
if (disposed) return () => {};
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
registerDirtySource(isDirty) {
|
||||
if (disposed) return () => {};
|
||||
dirtySources.add(isDirty);
|
||||
syncBeforeUnload();
|
||||
return () => {
|
||||
dirtySources.delete(isDirty);
|
||||
syncBeforeUnload();
|
||||
};
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
document?.removeEventListener("visibilitychange", onVisibility);
|
||||
host.removeEventListener("online", onOnline);
|
||||
host.removeEventListener("offline", onOffline);
|
||||
host.removeEventListener("focus", onFocus);
|
||||
host.removeEventListener("pagehide", onPageHide);
|
||||
host.removeEventListener("pageshow", onPageShow);
|
||||
if (beforeUnloadAttached) {
|
||||
host.removeEventListener("beforeunload", onBeforeUnload);
|
||||
beforeUnloadAttached = false;
|
||||
}
|
||||
listeners.clear();
|
||||
dirtySources.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { MutationIntentFactory } from "../../application/ports/mutation-intent-factory.ts";
|
||||
import { defineMutationIntent } from "../../contracts/mutation-intent.ts";
|
||||
|
||||
export type BrowserMutationIntentFactoryDependencies = Readonly<{
|
||||
randomUUID?: () => string;
|
||||
monotonicNow?: () => number;
|
||||
}>;
|
||||
|
||||
export function createBrowserMutationIntentFactory(
|
||||
dependencies: BrowserMutationIntentFactoryDependencies = {},
|
||||
): MutationIntentFactory {
|
||||
const randomUUID =
|
||||
dependencies.randomUUID ??
|
||||
(() => {
|
||||
if (
|
||||
typeof globalThis.crypto === "undefined" ||
|
||||
typeof globalThis.crypto.randomUUID !== "function"
|
||||
) {
|
||||
throw new TypeError("Secure mutation identity generation is unavailable.");
|
||||
}
|
||||
return globalThis.crypto.randomUUID();
|
||||
});
|
||||
const monotonicNow =
|
||||
dependencies.monotonicNow ??
|
||||
(() => {
|
||||
if (
|
||||
typeof globalThis.performance === "undefined" ||
|
||||
typeof globalThis.performance.now !== "function"
|
||||
) {
|
||||
throw new TypeError("Monotonic time is unavailable.");
|
||||
}
|
||||
return globalThis.performance.now();
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
create(input) {
|
||||
const intentId = randomUUID();
|
||||
const idempotencyKey = input.requiresIdempotencyKey
|
||||
? randomUUID()
|
||||
: undefined;
|
||||
return defineMutationIntent({
|
||||
intentId,
|
||||
operationId: input.operationId,
|
||||
canonicalInputIdentity: input.canonicalInputIdentity,
|
||||
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
|
||||
createdAtMonotonicMs: monotonicNow(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ClockPort } from "../../application/ports/clock-port.ts";
|
||||
|
||||
export const systemClock: ClockPort = Object.freeze({
|
||||
now: () => Date.now(),
|
||||
sleep(milliseconds, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(signal?.reason);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
|
||||
|
||||
export type ConditionalValidatorBinding = Readonly<{
|
||||
definitionId: string;
|
||||
identityToken: string;
|
||||
representationVersion: number;
|
||||
scope: CacheScopeSnapshot;
|
||||
}>;
|
||||
|
||||
export type ConditionalValidatorStore = Readonly<{
|
||||
install(
|
||||
binding: ConditionalValidatorBinding,
|
||||
validator: string,
|
||||
cacheRevision: number,
|
||||
): boolean;
|
||||
prepare(
|
||||
binding: ConditionalValidatorBinding,
|
||||
cacheRevision: number,
|
||||
): string | null;
|
||||
acceptNotModified(
|
||||
binding: ConditionalValidatorBinding,
|
||||
cacheRevision: number,
|
||||
hasMappedValue: boolean,
|
||||
): boolean;
|
||||
remove(binding: ConditionalValidatorBinding): void;
|
||||
clear(): void;
|
||||
}>;
|
||||
|
||||
type ValidatorRow = {
|
||||
validator: string;
|
||||
cacheRevision: number;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
export function createConditionalValidatorStore(
|
||||
maxEntries = 1_024,
|
||||
): ConditionalValidatorStore {
|
||||
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
||||
throw new TypeError("Invalid conditional validator capacity.");
|
||||
}
|
||||
const rows = new Map<string, ValidatorRow>();
|
||||
|
||||
function key(binding: ConditionalValidatorBinding): string | null {
|
||||
if (
|
||||
!binding.scope.isCurrent() ||
|
||||
!binding.definitionId ||
|
||||
!/^[A-Za-z0-9._:-]{16,128}$/.test(binding.identityToken) ||
|
||||
!Number.isSafeInteger(binding.representationVersion) ||
|
||||
binding.representationVersion < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
binding.scope.fingerprint,
|
||||
binding.definitionId,
|
||||
binding.identityToken,
|
||||
binding.representationVersion,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
install(binding, validator, cacheRevision) {
|
||||
const selectedKey = key(binding);
|
||||
if (
|
||||
!selectedKey ||
|
||||
!isSafeEntityTag(validator) ||
|
||||
!Number.isSafeInteger(cacheRevision) ||
|
||||
cacheRevision < 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!rows.has(selectedKey) && rows.size >= maxEntries) return false;
|
||||
rows.set(selectedKey, {
|
||||
validator,
|
||||
cacheRevision,
|
||||
generation: binding.scope.generation,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
prepare(binding, cacheRevision) {
|
||||
const selectedKey = key(binding);
|
||||
if (!selectedKey) return null;
|
||||
const row = rows.get(selectedKey);
|
||||
return row &&
|
||||
row.generation === binding.scope.generation &&
|
||||
row.cacheRevision === cacheRevision
|
||||
? row.validator
|
||||
: null;
|
||||
},
|
||||
acceptNotModified(binding, cacheRevision, hasMappedValue) {
|
||||
const selectedKey = key(binding);
|
||||
if (!selectedKey || !hasMappedValue) return false;
|
||||
const row = rows.get(selectedKey);
|
||||
return Boolean(
|
||||
row &&
|
||||
row.generation === binding.scope.generation &&
|
||||
row.cacheRevision === cacheRevision,
|
||||
);
|
||||
},
|
||||
remove(binding) {
|
||||
const selectedKey = key(binding);
|
||||
if (selectedKey) rows.delete(selectedKey);
|
||||
},
|
||||
clear() {
|
||||
rows.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isSafeEntityTag(value: string): boolean {
|
||||
if (value.length < 3 || value.length > 256) return false;
|
||||
const opaque = value.startsWith('W/"')
|
||||
? value.slice(3, -1)
|
||||
: value.startsWith('"')
|
||||
? value.slice(1, -1)
|
||||
: null;
|
||||
if (opaque === null || !value.endsWith('"')) return false;
|
||||
return [...opaque].every((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code === 0x21 || (code >= 0x23 && code <= 0x7e) ||
|
||||
(code >= 0x80 && code <= 0xff);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Result } from "../../application/result.ts";
|
||||
import type {
|
||||
CursorPage,
|
||||
CursorPaginationProfile,
|
||||
CursorPaginationRuntime,
|
||||
} from "../../contracts/cursor-pagination.ts";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
|
||||
export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
definitionId: string;
|
||||
profile: CursorPaginationProfile;
|
||||
loadPage(
|
||||
cursor: string | null,
|
||||
context: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<Result<CursorPage<Value>>>;
|
||||
}>): CursorPaginationRuntime<Value> {
|
||||
validateProfile(dependencies.profile);
|
||||
return Object.freeze({
|
||||
async loadAll(context) {
|
||||
const items: Value[] = [];
|
||||
const cursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let snapshot: string | null | undefined;
|
||||
for (
|
||||
let pageIndex = 0;
|
||||
pageIndex < dependencies.profile.maxPages;
|
||||
pageIndex += 1
|
||||
) {
|
||||
if (context.signal?.aborted) {
|
||||
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
|
||||
}
|
||||
const result = await dependencies.loadPage(cursor, context);
|
||||
if (!result.ok) return result;
|
||||
const page = result.value;
|
||||
if (!isValidPage(page, dependencies.profile)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_PAGE_INVALID",
|
||||
);
|
||||
}
|
||||
if (snapshot === undefined) {
|
||||
snapshot = page.snapshotToken;
|
||||
} else if (snapshot !== page.snapshotToken) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_SNAPSHOT_CHANGED",
|
||||
);
|
||||
}
|
||||
items.push(...page.items);
|
||||
if (
|
||||
items.length > dependencies.profile.maxTotalItems ||
|
||||
estimatedBytes(items) > dependencies.profile.maxEstimatedBytes
|
||||
) {
|
||||
return failure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
"PAGINATION_RESULT_LIMIT",
|
||||
);
|
||||
}
|
||||
if (!page.hasMore) return { ok: true, value: Object.freeze(items) };
|
||||
const nextCursor = page.nextCursor;
|
||||
if (!nextCursor || cursors.has(nextCursor)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_CURSOR_LOOP",
|
||||
);
|
||||
}
|
||||
cursors.add(nextCursor);
|
||||
cursor = nextCursor;
|
||||
}
|
||||
return failure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
"PAGINATION_PAGE_LIMIT",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function failure(
|
||||
kind:
|
||||
| "PAGINATION_CONTRACT_VIOLATION"
|
||||
| "RESULT_LIMIT_EXCEEDED"
|
||||
| "REQUEST_ABORTED",
|
||||
code: string,
|
||||
) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: createFailure(kind, dependencies.definitionId, 0, { code }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateProfile(profile: CursorPaginationProfile): void {
|
||||
if (
|
||||
!profile.profileId ||
|
||||
!Number.isSafeInteger(profile.maxPages) ||
|
||||
profile.maxPages < 1 ||
|
||||
profile.maxPages > 100 ||
|
||||
!Number.isSafeInteger(profile.maxTotalItems) ||
|
||||
profile.maxTotalItems < 1 ||
|
||||
!Number.isSafeInteger(profile.maxEstimatedBytes) ||
|
||||
profile.maxEstimatedBytes < 1 ||
|
||||
!Number.isSafeInteger(profile.maxCursorBytes) ||
|
||||
profile.maxCursorBytes < 1 ||
|
||||
profile.maxCursorBytes > 4_096
|
||||
) {
|
||||
throw new TypeError("Invalid cursor pagination profile.");
|
||||
}
|
||||
}
|
||||
|
||||
function isValidPage<Value>(
|
||||
page: CursorPage<Value>,
|
||||
profile: CursorPaginationProfile,
|
||||
): boolean {
|
||||
const encoder = new TextEncoder();
|
||||
return (
|
||||
Boolean(page) &&
|
||||
Array.isArray(page.items) &&
|
||||
typeof page.hasMore === "boolean" &&
|
||||
page.hasMore === (page.nextCursor !== null) &&
|
||||
(page.nextCursor === null ||
|
||||
(typeof page.nextCursor === "string" &&
|
||||
page.nextCursor.length > 0 &&
|
||||
encoder.encode(page.nextCursor).byteLength <=
|
||||
profile.maxCursorBytes)) &&
|
||||
(page.snapshotToken === null ||
|
||||
(typeof page.snapshotToken === "string" &&
|
||||
page.snapshotToken.length > 0 &&
|
||||
encoder.encode(page.snapshotToken).byteLength <=
|
||||
profile.maxCursorBytes)) &&
|
||||
(profile.allowSparsePage || !page.hasMore || page.items.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function estimatedBytes(value: unknown): number {
|
||||
try {
|
||||
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
|
||||
import type { QueryInvalidationCoordinator } from "../../contracts/query-invalidation.ts";
|
||||
import {
|
||||
createRuntimeIdentityRegistry,
|
||||
type RuntimeIdentityRegistry,
|
||||
} from "../../contracts/query-keys.ts";
|
||||
import type {
|
||||
CacheScopeSnapshot,
|
||||
ClientScopeLifecycleEvent,
|
||||
ClientScopePhase,
|
||||
ServerStateScopeRuntime,
|
||||
} from "../../contracts/server-state-scope.ts";
|
||||
|
||||
/**
|
||||
* Steps 4-11 of §10.6 that this runtime does not own directly. Each optional
|
||||
* capability registers its own closer so the ordering lives in one place rather
|
||||
* than being re-derived by every subsystem.
|
||||
*/
|
||||
export type ScopeResetParticipant = Readonly<{
|
||||
/** Lower runs earlier; the §10.6 step number is used as the rank. */
|
||||
order: number;
|
||||
label: string;
|
||||
close(): void | Promise<void>;
|
||||
}>;
|
||||
|
||||
export type ServerStateScopeDependencies = Readonly<{
|
||||
session: Pick<AuthSessionPort, "subscribe">;
|
||||
queryInvalidation: Pick<QueryInvalidationCoordinator, "resetLocal">;
|
||||
tokenFactory?: () => string;
|
||||
participants?: readonly ScopeResetParticipant[];
|
||||
activateNextGeneration?: () => void | Promise<void>;
|
||||
}>;
|
||||
|
||||
export function createServerStateScopeRuntime(
|
||||
dependencies: ServerStateScopeDependencies,
|
||||
): ServerStateScopeRuntime {
|
||||
const listeners = new Set<() => void>();
|
||||
const lifecycleListeners = new Set<
|
||||
(event: ClientScopeLifecycleEvent) => void
|
||||
>();
|
||||
const participants = [...(dependencies.participants ?? [])].sort(
|
||||
(left, right) => left.order - right.order,
|
||||
);
|
||||
|
||||
let generation = 1;
|
||||
let identities = newIdentityRegistry(dependencies.tokenFactory);
|
||||
let fingerprint = scopeFingerprint(dependencies.tokenFactory);
|
||||
let generationLifetime = new AbortController();
|
||||
let phase: ClientScopePhase = "READY";
|
||||
let disposed = false;
|
||||
let resetChain = Promise.resolve();
|
||||
|
||||
function createSnapshot(): CacheScopeSnapshot {
|
||||
const capturedGeneration = generation;
|
||||
const capturedIdentities = identities;
|
||||
return Object.freeze({
|
||||
generation: capturedGeneration,
|
||||
fingerprint,
|
||||
identities: capturedIdentities,
|
||||
signal: generationLifetime.signal,
|
||||
isCurrent: () =>
|
||||
!disposed &&
|
||||
phase === "READY" &&
|
||||
generation === capturedGeneration &&
|
||||
identities === capturedIdentities,
|
||||
});
|
||||
}
|
||||
let currentSnapshot = createSnapshot();
|
||||
|
||||
function publishLifecycle(event: ClientScopeLifecycleEvent): void {
|
||||
for (const listener of [...lifecycleListeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// One subscriber defect cannot stop the fence from propagating.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function publishSnapshot(): void {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// Subscriber defects are isolated from the mandatory reset sequence.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = dependencies.session.subscribe(() => {
|
||||
if (disposed) return;
|
||||
const previousIdentities = identities;
|
||||
const previousGeneration = generation;
|
||||
|
||||
// §10.6 steps 1-3 are synchronous: increment the generation, invalidate the
|
||||
// old snapshot, publish FENCED. Nothing between here and READY may render a
|
||||
// value that belonged to the previous identity.
|
||||
generationLifetime.abort();
|
||||
const targetGeneration = ++generation;
|
||||
phase = "FENCED";
|
||||
currentSnapshot = createSnapshot();
|
||||
publishLifecycle(
|
||||
Object.freeze({ kind: "FENCED" as const, previousGeneration }),
|
||||
);
|
||||
publishSnapshot();
|
||||
|
||||
resetChain = resetChain
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
let failed = false;
|
||||
// Steps 4-11: close admission, cancel and clear, release leases.
|
||||
for (const participant of participants) {
|
||||
try {
|
||||
await participant.close();
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await dependencies.queryInvalidation.resetLocal();
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
|
||||
previousIdentities.close();
|
||||
if (disposed || generation !== targetGeneration) return;
|
||||
|
||||
if (!failed) {
|
||||
try {
|
||||
await dependencies.activateNextGeneration?.();
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (disposed || generation !== targetGeneration) return;
|
||||
|
||||
if (failed) {
|
||||
phase = "FAILED";
|
||||
currentSnapshot = createSnapshot();
|
||||
publishLifecycle(
|
||||
Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
generation: targetGeneration,
|
||||
}),
|
||||
);
|
||||
publishSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
// Steps 12-15: new identity registry, READY, notify, reopen admission.
|
||||
identities = newIdentityRegistry(dependencies.tokenFactory);
|
||||
fingerprint = scopeFingerprint(dependencies.tokenFactory);
|
||||
generationLifetime = new AbortController();
|
||||
phase = "READY";
|
||||
currentSnapshot = createSnapshot();
|
||||
publishLifecycle(
|
||||
Object.freeze({ kind: "READY" as const, snapshot: currentSnapshot }),
|
||||
);
|
||||
publishSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
getSnapshot: () => currentSnapshot,
|
||||
getPhase: () => phase,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
subscribeLifecycle(listener: (event: ClientScopeLifecycleEvent) => void) {
|
||||
lifecycleListeners.add(listener);
|
||||
return () => lifecycleListeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
phase = "DISPOSED";
|
||||
generationLifetime.abort();
|
||||
unsubscribe();
|
||||
publishLifecycle(Object.freeze({ kind: "DISPOSED" as const }));
|
||||
listeners.clear();
|
||||
lifecycleListeners.clear();
|
||||
identities.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function newIdentityRegistry(
|
||||
tokenFactory: (() => string) | undefined,
|
||||
): RuntimeIdentityRegistry {
|
||||
return createRuntimeIdentityRegistry({
|
||||
...(tokenFactory ? { tokenFactory } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function scopeFingerprint(tokenFactory: (() => string) | undefined): string {
|
||||
const candidate = tokenFactory?.() ?? crypto.randomUUID();
|
||||
if (!/^[A-Za-z0-9._:-]{16,128}$/.test(candidate)) {
|
||||
throw new TypeError("Invalid cache scope fingerprint.");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type {
|
||||
InvalidationRegistryIndex,
|
||||
QueryInvalidationCoordinator,
|
||||
QueryInvalidationTopic,
|
||||
QueryMutationLease,
|
||||
} from "../../contracts/query-invalidation.ts";
|
||||
import { INVALIDATION_REGISTRY_BOUNDS } from "../../contracts/query-invalidation.ts";
|
||||
import {
|
||||
createQueryInvalidationPrefix,
|
||||
queryNamespaceIdentityKey,
|
||||
type QueryNamespaceIdentity,
|
||||
} from "../../contracts/query-keys.ts";
|
||||
import type {
|
||||
BrowserCrossContextInvalidation,
|
||||
CrossContextInvalidationDelivery,
|
||||
} from "../cross-context-invalidation/index.ts";
|
||||
|
||||
export type TanStackCacheCoordinatorDependencies = Readonly<{
|
||||
queryClient: QueryClient;
|
||||
invalidationIndex: InvalidationRegistryIndex;
|
||||
topicVersions: ReadonlyMap<string, number>;
|
||||
crossContext?: BrowserCrossContextInvalidation;
|
||||
diagnostics?: DiagnosticsPort;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Joins registry-owned invalidation topics to TanStack Query without putting a
|
||||
* query key or cached value on the cross-context wire.
|
||||
*/
|
||||
export function createTanStackCacheCoordinator(
|
||||
dependencies: TanStackCacheCoordinatorDependencies,
|
||||
): QueryInvalidationCoordinator {
|
||||
validateConfiguration(
|
||||
dependencies.invalidationIndex,
|
||||
dependencies.topicVersions,
|
||||
);
|
||||
const mutationLeases = new Map<QueryInvalidationTopic, number>();
|
||||
const pendingRemote = new Set<QueryInvalidationTopic>();
|
||||
let disposed = false;
|
||||
let resetting = false;
|
||||
let lifecycleGeneration = 0;
|
||||
let flushPromise: Promise<void> | null = null;
|
||||
let resetPromise: Promise<void> | null = null;
|
||||
|
||||
const unsubscribe = dependencies.crossContext?.subscribe((delivery) => {
|
||||
receiveRemote(delivery);
|
||||
});
|
||||
|
||||
function namespacesFor(
|
||||
topic: QueryInvalidationTopic,
|
||||
): readonly QueryNamespaceIdentity[] {
|
||||
const selected = dependencies.invalidationIndex.namespacesForTopic.get(topic);
|
||||
if (!selected) {
|
||||
throw new TypeError("Unregistered query invalidation topic.");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function invalidateLocal(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
expectedGeneration = lifecycleGeneration,
|
||||
): Promise<void> {
|
||||
if (
|
||||
disposed ||
|
||||
resetting ||
|
||||
expectedGeneration !== lifecycleGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const namespaces = new Map<string, QueryNamespaceIdentity>();
|
||||
for (const topic of topics) {
|
||||
for (const namespace of namespacesFor(topic)) {
|
||||
namespaces.set(queryNamespaceIdentityKey(namespace), namespace);
|
||||
}
|
||||
}
|
||||
for (const namespace of namespaces.values()) {
|
||||
try {
|
||||
await dependencies.queryClient.invalidateQueries({
|
||||
queryKey: createQueryInvalidationPrefix(namespace),
|
||||
exact: false,
|
||||
refetchType: "active",
|
||||
});
|
||||
} catch {
|
||||
report("invalidate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function receiveRemote(
|
||||
delivery: CrossContextInvalidationDelivery,
|
||||
): void {
|
||||
if (disposed) return;
|
||||
if (
|
||||
!dependencies.invalidationIndex.namespacesForTopic.has(
|
||||
delivery.event.topic,
|
||||
)
|
||||
) {
|
||||
report("unknown-topic");
|
||||
return;
|
||||
}
|
||||
const selectedTopic = delivery.event.topic as QueryInvalidationTopic;
|
||||
|
||||
if (delivery.ordering === "GAP") {
|
||||
for (const topic of dependencies.invalidationIndex.namespacesForTopic.keys()) {
|
||||
pendingRemote.add(topic as QueryInvalidationTopic);
|
||||
}
|
||||
report("sequence-gap");
|
||||
} else {
|
||||
pendingRemote.add(selectedTopic);
|
||||
}
|
||||
if (!resetting) void flushRemote();
|
||||
}
|
||||
|
||||
function flushRemote(): Promise<void> {
|
||||
if (disposed || resetting) return Promise.resolve();
|
||||
if (flushPromise) return flushPromise;
|
||||
const expectedGeneration = lifecycleGeneration;
|
||||
|
||||
flushPromise = Promise.resolve()
|
||||
.then(async () => {
|
||||
while (
|
||||
!disposed &&
|
||||
!resetting &&
|
||||
expectedGeneration === lifecycleGeneration
|
||||
) {
|
||||
const ready = [...pendingRemote].filter(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
);
|
||||
if (ready.length === 0) return;
|
||||
for (const topic of ready) {
|
||||
pendingRemote.delete(topic);
|
||||
}
|
||||
await invalidateLocal(ready, expectedGeneration);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
report("remote-flush");
|
||||
})
|
||||
.finally(() => {
|
||||
flushPromise = null;
|
||||
if (
|
||||
!disposed &&
|
||||
!resetting &&
|
||||
[...pendingRemote].some(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
)
|
||||
) {
|
||||
void flushRemote();
|
||||
}
|
||||
});
|
||||
return flushPromise;
|
||||
}
|
||||
|
||||
function uniqueTopics(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): readonly QueryInvalidationTopic[] {
|
||||
const unique = [...new Set(topics)];
|
||||
for (const topic of unique) namespacesFor(topic);
|
||||
return unique;
|
||||
}
|
||||
|
||||
function report(operation: string): void {
|
||||
try {
|
||||
dependencies.diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation,
|
||||
error_kind: "QUERY_CACHE_FAILURE",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Cache correctness and cleanup do not depend on diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async invalidate(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): Promise<void> {
|
||||
if (disposed) return;
|
||||
const selectedTopics = uniqueTopics(topics);
|
||||
await invalidateLocal(selectedTopics);
|
||||
for (const topic of selectedTopics) {
|
||||
const topicVersion = dependencies.topicVersions.get(topic);
|
||||
if (topicVersion === undefined) {
|
||||
throw new TypeError("Unregistered query invalidation topic.");
|
||||
}
|
||||
const published = dependencies.crossContext?.publish({
|
||||
topic,
|
||||
topicVersion,
|
||||
});
|
||||
if (published && !published.ok) {
|
||||
report("cross-context-publish");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
beginMutation(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): QueryMutationLease {
|
||||
if (disposed) {
|
||||
throw new TypeError("Query invalidation coordinator is disposed.");
|
||||
}
|
||||
const selectedTopics = uniqueTopics(topics);
|
||||
for (const topic of selectedTopics) {
|
||||
mutationLeases.set(
|
||||
topic,
|
||||
(mutationLeases.get(topic) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
let released = false;
|
||||
return Object.freeze({
|
||||
async release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
for (const topic of selectedTopics) {
|
||||
const remaining = (mutationLeases.get(topic) ?? 1) - 1;
|
||||
if (remaining <= 0) {
|
||||
mutationLeases.delete(topic);
|
||||
} else {
|
||||
mutationLeases.set(topic, remaining);
|
||||
}
|
||||
}
|
||||
await flushRemote();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async resetLocal() {
|
||||
if (disposed) return;
|
||||
if (resetPromise) return resetPromise;
|
||||
resetting = true;
|
||||
lifecycleGeneration += 1;
|
||||
pendingRemote.clear();
|
||||
mutationLeases.clear();
|
||||
const activeFlush = flushPromise;
|
||||
resetPromise = (async () => {
|
||||
try {
|
||||
await activeFlush;
|
||||
} catch {
|
||||
report("reset-flush");
|
||||
}
|
||||
let cancellationFailed = false;
|
||||
try {
|
||||
await dependencies.queryClient.cancelQueries();
|
||||
} catch {
|
||||
report("reset-cancel");
|
||||
cancellationFailed = true;
|
||||
}
|
||||
dependencies.queryClient.clear();
|
||||
if (cancellationFailed) {
|
||||
throw new TypeError("mandatory query cancellation failed");
|
||||
}
|
||||
})().finally(() => {
|
||||
resetting = false;
|
||||
resetPromise = null;
|
||||
if (
|
||||
!disposed &&
|
||||
[...pendingRemote].some(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
)
|
||||
) {
|
||||
void flushRemote();
|
||||
}
|
||||
});
|
||||
return resetPromise;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
dependencies.crossContext?.close();
|
||||
pendingRemote.clear();
|
||||
mutationLeases.clear();
|
||||
flushPromise = null;
|
||||
resetPromise = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function validateConfiguration(
|
||||
index: InvalidationRegistryIndex,
|
||||
topicVersions: ReadonlyMap<string, number>,
|
||||
): void {
|
||||
if (
|
||||
index.namespacesForTopic.size > INVALIDATION_REGISTRY_BOUNDS.maxTopics ||
|
||||
topicVersions.size !== index.namespacesForTopic.size
|
||||
) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
for (const [topic, namespaces] of index.namespacesForTopic) {
|
||||
const version = topicVersions.get(topic);
|
||||
if (
|
||||
namespaces.length < 1 ||
|
||||
!Number.isSafeInteger(version) ||
|
||||
(version ?? 0) < 1
|
||||
) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
const namespaceKeys = new Set<string>();
|
||||
for (const namespace of namespaces) {
|
||||
const key = queryNamespaceIdentityKey(namespace);
|
||||
if (namespaceKeys.has(key)) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
namespaceKeys.add(key);
|
||||
}
|
||||
}
|
||||
for (const topic of topicVersions.keys()) {
|
||||
if (!index.namespacesForTopic.has(topic)) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import { safeErrorKind } from "../../contracts/diagnostics.ts";
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type { QueryCachePort } from "../../application/ports/query-cache-port.ts";
|
||||
|
||||
export type QueryCacheDependencies = Readonly<{
|
||||
diagnostics?: DiagnosticsPort;
|
||||
}>;
|
||||
|
||||
export const QUERY_CACHE_DEFAULTS = Object.freeze({
|
||||
staleTime: 30_000,
|
||||
gcTime: 300_000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
mutationRetry: false,
|
||||
persistence: false,
|
||||
});
|
||||
|
||||
export function createQueryClient(
|
||||
dependencies: QueryCacheDependencies = {},
|
||||
): QueryClient {
|
||||
function report(operation: string, error: unknown): void {
|
||||
try {
|
||||
dependencies.diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation,
|
||||
error_kind: safeErrorKind(error),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Query behavior remains independent from diagnostics.
|
||||
}
|
||||
}
|
||||
return new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => report("query", error),
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => report("mutation", error),
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: QUERY_CACHE_DEFAULTS.staleTime,
|
||||
gcTime: QUERY_CACHE_DEFAULTS.gcTime,
|
||||
refetchOnWindowFocus: QUERY_CACHE_DEFAULTS.refetchOnWindowFocus,
|
||||
retry: QUERY_CACHE_DEFAULTS.retry,
|
||||
},
|
||||
mutations: {
|
||||
retry: QUERY_CACHE_DEFAULTS.mutationRetry,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createQueryCacheAdapter(
|
||||
queryClient: QueryClient,
|
||||
dependencies: QueryCacheDependencies = {},
|
||||
): QueryCachePort {
|
||||
return Object.freeze({
|
||||
read(key) {
|
||||
try {
|
||||
return { ok: true, value: queryClient.getQueryData(key) };
|
||||
} catch {
|
||||
return cacheFailure("read", key, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
write(key, value) {
|
||||
try {
|
||||
queryClient.setQueryData(key, structuredClone(value));
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return cacheFailure("write", key, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
async invalidate(namespace) {
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: namespace, exact: false });
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return cacheFailure("invalidate", namespace, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cacheFailure(
|
||||
phase: string,
|
||||
key: readonly unknown[],
|
||||
diagnostics: DiagnosticsPort | undefined,
|
||||
): Readonly<{ ok: false; error: ReturnType<typeof createFailure> }> {
|
||||
const namespace = typeof key[0] === "string" ? key[0] : "unknown";
|
||||
try {
|
||||
diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation: phase,
|
||||
error_kind: "QUERY_CACHE_FAILURE",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Cache behavior remains independent from diagnostics.
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
|
||||
code: `QUERY_CACHE_${phase.toUpperCase()}_FAILED`,
|
||||
causeClass: `namespace:${namespace}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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(",")}}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -0,0 +1,43 @@
|
||||
export {
|
||||
createWebSocketConnection,
|
||||
WEBSOCKET_IMPLEMENTATION_CEILINGS,
|
||||
type WebSocketClientCeilings,
|
||||
type WebSocketClosedReceipt,
|
||||
type WebSocketConnection,
|
||||
type WebSocketConnectionDependencies,
|
||||
type WebSocketConnectionObservation,
|
||||
type WebSocketConnectionSnapshot,
|
||||
type WebSocketConnectionStatus,
|
||||
type WebSocketFacade,
|
||||
type WebSocketInboundEventOutcome,
|
||||
type WebSocketLocalSendReceipt,
|
||||
type WebSocketOpenReceipt,
|
||||
type WebSocketRecoveryRequest,
|
||||
type WebSocketResumeCheckpoint,
|
||||
type WebSocketSubscribedReceipt,
|
||||
type WebSocketSubscriptionRequest,
|
||||
} from "./websocket-connection.ts";
|
||||
export {
|
||||
decodeWebSocketServerFrame,
|
||||
encodeWebSocketClientFrame,
|
||||
nextUnsignedSequence,
|
||||
REALTIME_WEBSOCKET_PROTOCOL,
|
||||
type WebSocketAdvertisedLimits,
|
||||
type WebSocketClientCloseFrame,
|
||||
type WebSocketClientFrame,
|
||||
type WebSocketCloseCategory,
|
||||
type WebSocketEventFrame,
|
||||
type WebSocketHeartbeatAckFrame,
|
||||
type WebSocketHeartbeatFrame,
|
||||
type WebSocketProtocolFailure,
|
||||
type WebSocketProtocolResult,
|
||||
type WebSocketResetReason,
|
||||
type WebSocketResetRequiredFrame,
|
||||
type WebSocketServerCloseFrame,
|
||||
type WebSocketServerFrame,
|
||||
type WebSocketSubscribedFrame,
|
||||
type WebSocketSubscribeFrame,
|
||||
type WebSocketUnsubscribedFrame,
|
||||
type WebSocketUnsubscribeFrame,
|
||||
type WebSocketWelcomeFrame,
|
||||
} from "./websocket-protocol.ts";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,518 @@
|
||||
import {
|
||||
hasDuplicateJsonMembers,
|
||||
} from "../json-member-scanner.ts";
|
||||
|
||||
export const REALTIME_WEBSOCKET_PROTOCOL = "realtime.v1" as const;
|
||||
|
||||
export type WebSocketCloseCategory =
|
||||
| "NORMAL"
|
||||
| "RESTART"
|
||||
| "OVERLOADED"
|
||||
| "AUTH_REQUIRED"
|
||||
| "FORBIDDEN"
|
||||
| "PROTOCOL_MISMATCH"
|
||||
| "CURSOR_RESET"
|
||||
| "NETWORK_LOST";
|
||||
|
||||
export type WebSocketResetReason =
|
||||
| "CURSOR_EXPIRED"
|
||||
| "SEQUENCE_GAP"
|
||||
| "SERVER_RESET"
|
||||
| "SCOPE_CHANGED";
|
||||
|
||||
export type WebSocketAdvertisedLimits = Readonly<{
|
||||
maxFrameBytes: number;
|
||||
maxSubscriptions: number;
|
||||
maxInboundQueueCount: number;
|
||||
maxInboundQueueBytes: number;
|
||||
maxOutboundQueueCount: number;
|
||||
maxOutboundQueueBytes: number;
|
||||
maxBufferedAmountBytes: number;
|
||||
maxEventsPerSecond: number;
|
||||
}>;
|
||||
|
||||
export type WebSocketWelcomeFrame = Readonly<{
|
||||
type: "WELCOME";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
connectionId: string;
|
||||
heartbeatMs: number;
|
||||
heartbeatAckTimeoutMs: number;
|
||||
limits: WebSocketAdvertisedLimits;
|
||||
}>;
|
||||
|
||||
export type WebSocketSubscribedFrame = Readonly<{
|
||||
type: "SUBSCRIBED";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
subscriptionId: string;
|
||||
streamEpoch: string;
|
||||
acceptedCursor: string | null;
|
||||
nextExpectedSequence: string;
|
||||
}>;
|
||||
|
||||
export type WebSocketUnsubscribedFrame = Readonly<{
|
||||
type: "UNSUBSCRIBED";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
subscriptionId: string;
|
||||
}>;
|
||||
|
||||
export type WebSocketEventFrame = Readonly<{
|
||||
type: "EVENT";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
subscriptionId: string;
|
||||
envelope: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export type WebSocketResetRequiredFrame = Readonly<{
|
||||
type: "RESET_REQUIRED";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
subscriptionId: string;
|
||||
reason: WebSocketResetReason;
|
||||
}>;
|
||||
|
||||
export type WebSocketHeartbeatAckFrame = Readonly<{
|
||||
type: "HEARTBEAT_ACK";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
nonce: string;
|
||||
}>;
|
||||
|
||||
export type WebSocketServerCloseFrame = Readonly<{
|
||||
type: "CLOSE";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
category: WebSocketCloseCategory;
|
||||
}>;
|
||||
|
||||
export type WebSocketServerFrame =
|
||||
| WebSocketWelcomeFrame
|
||||
| WebSocketSubscribedFrame
|
||||
| WebSocketUnsubscribedFrame
|
||||
| WebSocketEventFrame
|
||||
| WebSocketResetRequiredFrame
|
||||
| WebSocketHeartbeatAckFrame
|
||||
| WebSocketServerCloseFrame;
|
||||
|
||||
export type WebSocketSubscribeFrame = Readonly<{
|
||||
type: "SUBSCRIBE";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
subscriptionId: string;
|
||||
streamId: string;
|
||||
cursor: string | null;
|
||||
scopeBinding: string;
|
||||
}>;
|
||||
|
||||
export type WebSocketUnsubscribeFrame = Readonly<{
|
||||
type: "UNSUBSCRIBE";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
subscriptionId: string;
|
||||
}>;
|
||||
|
||||
export type WebSocketHeartbeatFrame = Readonly<{
|
||||
type: "HEARTBEAT";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
nonce: string;
|
||||
}>;
|
||||
|
||||
export type WebSocketClientCloseFrame = Readonly<{
|
||||
type: "CLOSE";
|
||||
protocol: typeof REALTIME_WEBSOCKET_PROTOCOL;
|
||||
category: WebSocketCloseCategory;
|
||||
}>;
|
||||
|
||||
export type WebSocketClientFrame =
|
||||
| WebSocketSubscribeFrame
|
||||
| WebSocketUnsubscribeFrame
|
||||
| WebSocketHeartbeatFrame
|
||||
| WebSocketClientCloseFrame;
|
||||
|
||||
export type WebSocketProtocolFailure = Readonly<{
|
||||
code:
|
||||
| "BINARY_FRAME"
|
||||
| "FRAME_TOO_LARGE"
|
||||
| "MALFORMED_FRAME"
|
||||
| "PROTOCOL_MISMATCH"
|
||||
| "UNKNOWN_FRAME";
|
||||
}>;
|
||||
|
||||
export type WebSocketProtocolResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value; byteLength: number }>
|
||||
| Readonly<{ ok: false; error: WebSocketProtocolFailure }>;
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
||||
const OPAQUE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/u;
|
||||
const UNSIGNED_DECIMAL = /^(?:0|[1-9][0-9]{0,19})$/u;
|
||||
const UINT64_MAX = 18_446_744_073_709_551_615n;
|
||||
const MAX_FRAME_STRUCTURE_DEPTH = 32;
|
||||
const MAX_FRAME_STRUCTURE_NODES = 4_096;
|
||||
const CLOSE_CATEGORIES: readonly WebSocketCloseCategory[] = [
|
||||
"NORMAL",
|
||||
"RESTART",
|
||||
"OVERLOADED",
|
||||
"AUTH_REQUIRED",
|
||||
"FORBIDDEN",
|
||||
"PROTOCOL_MISMATCH",
|
||||
"CURSOR_RESET",
|
||||
"NETWORK_LOST",
|
||||
];
|
||||
const RESET_REASONS: readonly WebSocketResetReason[] = [
|
||||
"CURSOR_EXPIRED",
|
||||
"SEQUENCE_GAP",
|
||||
"SERVER_RESET",
|
||||
"SCOPE_CHANGED",
|
||||
];
|
||||
const LIMIT_KEYS = [
|
||||
"maxBufferedAmountBytes",
|
||||
"maxEventsPerSecond",
|
||||
"maxFrameBytes",
|
||||
"maxInboundQueueBytes",
|
||||
"maxInboundQueueCount",
|
||||
"maxOutboundQueueBytes",
|
||||
"maxOutboundQueueCount",
|
||||
"maxSubscriptions",
|
||||
] as const;
|
||||
|
||||
const SERVER_KEYS = Object.freeze({
|
||||
WELCOME: [
|
||||
"connectionId",
|
||||
"heartbeatAckTimeoutMs",
|
||||
"heartbeatMs",
|
||||
"limits",
|
||||
"protocol",
|
||||
"type",
|
||||
],
|
||||
SUBSCRIBED: [
|
||||
"acceptedCursor",
|
||||
"nextExpectedSequence",
|
||||
"protocol",
|
||||
"streamEpoch",
|
||||
"subscriptionId",
|
||||
"type",
|
||||
],
|
||||
UNSUBSCRIBED: ["protocol", "subscriptionId", "type"],
|
||||
EVENT: ["envelope", "protocol", "subscriptionId", "type"],
|
||||
RESET_REQUIRED: [
|
||||
"protocol",
|
||||
"reason",
|
||||
"subscriptionId",
|
||||
"type",
|
||||
],
|
||||
HEARTBEAT_ACK: ["nonce", "protocol", "type"],
|
||||
CLOSE: ["category", "protocol", "type"],
|
||||
} satisfies Record<string, readonly string[]>);
|
||||
|
||||
const CLIENT_KEYS = Object.freeze({
|
||||
SUBSCRIBE: [
|
||||
"cursor",
|
||||
"protocol",
|
||||
"scopeBinding",
|
||||
"streamId",
|
||||
"subscriptionId",
|
||||
"type",
|
||||
],
|
||||
UNSUBSCRIBE: ["protocol", "subscriptionId", "type"],
|
||||
HEARTBEAT: ["nonce", "protocol", "type"],
|
||||
CLOSE: ["category", "protocol", "type"],
|
||||
} satisfies Record<string, readonly string[]>);
|
||||
|
||||
export function decodeWebSocketServerFrame(
|
||||
input: unknown,
|
||||
maxFrameBytes: number,
|
||||
): WebSocketProtocolResult<WebSocketServerFrame> {
|
||||
if (typeof input !== "string") {
|
||||
return protocolFailure("BINARY_FRAME");
|
||||
}
|
||||
if (!isPositiveInteger(maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
const byteLength = utf8ByteLength(input);
|
||||
if (byteLength > maxFrameBytes) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
if (
|
||||
hasDuplicateJsonMembers(input, {
|
||||
maxDepth: MAX_FRAME_STRUCTURE_DEPTH,
|
||||
maxMembers: MAX_FRAME_STRUCTURE_NODES,
|
||||
})
|
||||
) {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input);
|
||||
} catch {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
if (!isRecord(parsed) || typeof parsed.type !== "string") {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
if (parsed.protocol !== REALTIME_WEBSOCKET_PROTOCOL) {
|
||||
return protocolFailure("PROTOCOL_MISMATCH");
|
||||
}
|
||||
|
||||
const frame = decodeKnownServerFrame(parsed);
|
||||
if (!frame) {
|
||||
return protocolFailure(
|
||||
Object.hasOwn(SERVER_KEYS, parsed.type)
|
||||
? "MALFORMED_FRAME"
|
||||
: "UNKNOWN_FRAME",
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (!freezeBoundedJsonTree(frame)) {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
value: frame,
|
||||
byteLength,
|
||||
});
|
||||
} catch {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeWebSocketClientFrame(
|
||||
frame: WebSocketClientFrame,
|
||||
maxFrameBytes: number,
|
||||
): WebSocketProtocolResult<string> {
|
||||
if (
|
||||
!isPositiveInteger(maxFrameBytes) ||
|
||||
!isRecord(frame) ||
|
||||
frame.protocol !== REALTIME_WEBSOCKET_PROTOCOL ||
|
||||
typeof frame.type !== "string"
|
||||
) {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
const keys = CLIENT_KEYS[frame.type as keyof typeof CLIENT_KEYS];
|
||||
if (!keys || !hasExactKeys(frame, keys) || !isValidClientFrame(frame)) {
|
||||
return protocolFailure(
|
||||
keys ? "MALFORMED_FRAME" : "UNKNOWN_FRAME",
|
||||
);
|
||||
}
|
||||
let value: string;
|
||||
try {
|
||||
value = JSON.stringify(frame);
|
||||
} catch {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
const byteLength = utf8ByteLength(value);
|
||||
if (byteLength > maxFrameBytes) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
return Object.freeze({ ok: true, value, byteLength });
|
||||
}
|
||||
|
||||
export function nextUnsignedSequence(
|
||||
sequence: string,
|
||||
): string | null {
|
||||
if (!isUnsignedSequence(sequence)) return null;
|
||||
const value = BigInt(sequence);
|
||||
return value === UINT64_MAX ? null : String(value + 1n);
|
||||
}
|
||||
|
||||
function decodeKnownServerFrame(
|
||||
frame: Record<string, unknown>,
|
||||
): WebSocketServerFrame | null {
|
||||
switch (frame.type) {
|
||||
case "WELCOME":
|
||||
if (
|
||||
!hasExactKeys(frame, SERVER_KEYS.WELCOME) ||
|
||||
!isIdentifier(frame.connectionId) ||
|
||||
!isPositiveInteger(frame.heartbeatMs) ||
|
||||
!isPositiveInteger(frame.heartbeatAckTimeoutMs) ||
|
||||
!isAdvertisedLimits(frame.limits)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return frame as WebSocketWelcomeFrame;
|
||||
case "SUBSCRIBED":
|
||||
if (
|
||||
!hasExactKeys(frame, SERVER_KEYS.SUBSCRIBED) ||
|
||||
!isIdentifier(frame.subscriptionId) ||
|
||||
!isIdentifier(frame.streamEpoch) ||
|
||||
!isOptionalOpaque(frame.acceptedCursor) ||
|
||||
!isUnsignedSequence(frame.nextExpectedSequence)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return frame as WebSocketSubscribedFrame;
|
||||
case "UNSUBSCRIBED":
|
||||
if (
|
||||
!hasExactKeys(frame, SERVER_KEYS.UNSUBSCRIBED) ||
|
||||
!isIdentifier(frame.subscriptionId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return frame as WebSocketUnsubscribedFrame;
|
||||
case "EVENT":
|
||||
if (
|
||||
!hasExactKeys(frame, SERVER_KEYS.EVENT) ||
|
||||
!isIdentifier(frame.subscriptionId) ||
|
||||
!isRecord(frame.envelope)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return frame as WebSocketEventFrame;
|
||||
case "RESET_REQUIRED":
|
||||
if (
|
||||
!hasExactKeys(frame, SERVER_KEYS.RESET_REQUIRED) ||
|
||||
!isIdentifier(frame.subscriptionId) ||
|
||||
!RESET_REASONS.includes(frame.reason as WebSocketResetReason)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return frame as WebSocketResetRequiredFrame;
|
||||
case "HEARTBEAT_ACK":
|
||||
if (
|
||||
!hasExactKeys(frame, SERVER_KEYS.HEARTBEAT_ACK) ||
|
||||
!isIdentifier(frame.nonce)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return frame as WebSocketHeartbeatAckFrame;
|
||||
case "CLOSE":
|
||||
if (
|
||||
!hasExactKeys(frame, SERVER_KEYS.CLOSE) ||
|
||||
!CLOSE_CATEGORIES.includes(
|
||||
frame.category as WebSocketCloseCategory,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return frame as WebSocketServerCloseFrame;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidClientFrame(
|
||||
frame: Record<string, unknown>,
|
||||
): boolean {
|
||||
switch (frame.type) {
|
||||
case "SUBSCRIBE":
|
||||
return (
|
||||
isIdentifier(frame.subscriptionId) &&
|
||||
isIdentifier(frame.streamId) &&
|
||||
isOptionalOpaque(frame.cursor) &&
|
||||
isOpaque(frame.scopeBinding)
|
||||
);
|
||||
case "UNSUBSCRIBE":
|
||||
return isIdentifier(frame.subscriptionId);
|
||||
case "HEARTBEAT":
|
||||
return isIdentifier(frame.nonce);
|
||||
case "CLOSE":
|
||||
return CLOSE_CATEGORIES.includes(
|
||||
frame.category as WebSocketCloseCategory,
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAdvertisedLimits(
|
||||
input: unknown,
|
||||
): input is WebSocketAdvertisedLimits {
|
||||
if (!isRecord(input) || !hasExactKeys(input, LIMIT_KEYS)) {
|
||||
return false;
|
||||
}
|
||||
return LIMIT_KEYS.every((key) => isPositiveInteger(input[key]));
|
||||
}
|
||||
|
||||
function isRecord(
|
||||
input: unknown,
|
||||
): input is Record<string, unknown> {
|
||||
return (
|
||||
typeof input === "object" &&
|
||||
input !== null &&
|
||||
!Array.isArray(input) &&
|
||||
Object.getPrototypeOf(input) === Object.prototype
|
||||
);
|
||||
}
|
||||
|
||||
function hasExactKeys(
|
||||
input: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
): boolean {
|
||||
const keys = Object.keys(input).sort();
|
||||
return (
|
||||
keys.length === expected.length &&
|
||||
keys.every((key, index) => key === expected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function isIdentifier(input: unknown): input is string {
|
||||
return typeof input === "string" && IDENTIFIER.test(input);
|
||||
}
|
||||
|
||||
function isOpaque(input: unknown): input is string {
|
||||
return typeof input === "string" && OPAQUE_VALUE.test(input);
|
||||
}
|
||||
|
||||
function isOptionalOpaque(input: unknown): input is string | null {
|
||||
return input === null || isOpaque(input);
|
||||
}
|
||||
|
||||
function isPositiveInteger(input: unknown): input is number {
|
||||
return Number.isSafeInteger(input) && Number(input) > 0;
|
||||
}
|
||||
|
||||
function isUnsignedSequence(input: unknown): input is string {
|
||||
if (typeof input !== "string" || !UNSIGNED_DECIMAL.test(input)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return BigInt(input) <= UINT64_MAX;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function utf8ByteLength(input: string): number {
|
||||
return new TextEncoder().encode(input).byteLength;
|
||||
}
|
||||
|
||||
function protocolFailure(
|
||||
code: WebSocketProtocolFailure["code"],
|
||||
): WebSocketProtocolResult<never> {
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
error: Object.freeze({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
function freezeBoundedJsonTree(root: object): boolean {
|
||||
const pending: Array<
|
||||
Readonly<{
|
||||
value: object;
|
||||
depth: number;
|
||||
freeze: boolean;
|
||||
}>
|
||||
> = [{ value: root, depth: 0, freeze: false }];
|
||||
let discoveredNodes = 1;
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (!current) return false;
|
||||
if (current.freeze) {
|
||||
Object.freeze(current.value);
|
||||
continue;
|
||||
}
|
||||
if (current.depth > MAX_FRAME_STRUCTURE_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
pending.push({ ...current, freeze: true });
|
||||
for (const child of Object.values(current.value)) {
|
||||
if (child !== null && typeof child === "object") {
|
||||
discoveredNodes += 1;
|
||||
if (discoveredNodes > MAX_FRAME_STRUCTURE_NODES) {
|
||||
return false;
|
||||
}
|
||||
pending.push({
|
||||
value: child,
|
||||
depth: current.depth + 1,
|
||||
freeze: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { OFFLINE_SYNC_TAG } from "../../contracts/offline-command.ts";
|
||||
import type {
|
||||
ServiceWorkerHandlerId,
|
||||
ServiceWorkerProtocolIdentity,
|
||||
StaticAssetManifestV1,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
import {
|
||||
createServiceWorkerRuntime,
|
||||
type WorkerScopeLike,
|
||||
} from "./service-worker-lifecycle.ts";
|
||||
import { parseServiceWorkerMessage } from "./service-worker-protocol.ts";
|
||||
|
||||
/**
|
||||
* §17.1. The one physical worker entry for this scope.
|
||||
*
|
||||
* PWA lifecycle, verified static asset fetch, Web Push and the optional sync
|
||||
* wake-up are all handler factories inside this single entry. A second
|
||||
* registration for any of them is prohibited.
|
||||
*
|
||||
* This module is compiled only by `vite.service-worker.config.ts` when the
|
||||
* static selection is `ACTIVE`; it is never part of the page bundle.
|
||||
*/
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// Build-time virtual modules (§18.3). They resolve through the Service Worker
|
||||
// Vite config only, so the page bundle can never import a worker asset list.
|
||||
declare const __CA_SERVICE_WORKER_BUILD_INFO__: ServiceWorkerProtocolIdentity;
|
||||
declare const __CA_SERVICE_WORKER_ASSETS__: StaticAssetManifestV1 | null;
|
||||
declare const __CA_SERVICE_WORKER_HANDLERS__: readonly ServiceWorkerHandlerId[];
|
||||
declare const __CA_RUNTIME_CONFIG_URL__: string;
|
||||
declare const __CA_RELEASE_MANIFEST_URL__: string;
|
||||
|
||||
const identity = __CA_SERVICE_WORKER_BUILD_INFO__;
|
||||
const handlers = __CA_SERVICE_WORKER_HANDLERS__;
|
||||
|
||||
const scope: WorkerScopeLike = {
|
||||
caches: {
|
||||
open: (name) => caches.open(name),
|
||||
keys: () => caches.keys(),
|
||||
delete: (name) => caches.delete(name),
|
||||
match: (request) => caches.match(request),
|
||||
},
|
||||
clients: {
|
||||
matchAll: (options) =>
|
||||
self.clients.matchAll(
|
||||
options as { type?: "window"; includeUncontrolled?: boolean },
|
||||
) as Promise<
|
||||
readonly {
|
||||
id: string;
|
||||
url: string;
|
||||
postMessage(m: unknown): void;
|
||||
}[]
|
||||
>,
|
||||
},
|
||||
registrationScope: self.registration.scope,
|
||||
skipWaiting: () => self.skipWaiting(),
|
||||
fetcher: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init),
|
||||
async digest(bytes) {
|
||||
const buffer = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
bytes.slice().buffer as ArrayBuffer,
|
||||
);
|
||||
let hex = "";
|
||||
for (const byte of new Uint8Array(buffer)) {
|
||||
hex += byte.toString(16).padStart(2, "0");
|
||||
}
|
||||
return `sha256:${hex}`;
|
||||
},
|
||||
};
|
||||
|
||||
const runtime = createServiceWorkerRuntime(scope, {
|
||||
identity,
|
||||
handlers,
|
||||
manifest: __CA_SERVICE_WORKER_ASSETS__,
|
||||
runtimeConfigUrl: __CA_RUNTIME_CONFIG_URL__,
|
||||
releaseManifestUrl: __CA_RELEASE_MANIFEST_URL__,
|
||||
});
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// §17.10. Install never calls skipWaiting(); activation is a page handshake.
|
||||
event.waitUntil(runtime.onInstall());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
// §17.12. No clients.claim() in the baseline.
|
||||
event.waitUntil(runtime.onActivate());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
event.respondWith(
|
||||
runtime
|
||||
.onFetch({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
})
|
||||
.then((cached) => cached ?? fetch(request)),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
const parsed = parseServiceWorkerMessage(event.data);
|
||||
if (!parsed.ok) return;
|
||||
if (parsed.message.kind === "ACTIVATE_REQUEST") {
|
||||
event.waitUntil(runtime.onActivateRequest(event.data));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "CLIENT_DRAINED" ||
|
||||
parsed.message.kind === "ACTIVATE_REJECTED"
|
||||
) {
|
||||
const source = event.source;
|
||||
if (source && "id" in source && typeof source.id === "string") {
|
||||
runtime.onClientMessage(event.data, source.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (parsed.message.kind === "CACHE_RESET_REQUEST") {
|
||||
const source = event.source;
|
||||
if (
|
||||
source &&
|
||||
"id" in source &&
|
||||
typeof source.id === "string" &&
|
||||
"postMessage" in source &&
|
||||
typeof source.postMessage === "function"
|
||||
) {
|
||||
event.waitUntil(runtime.onCacheResetRequest(event.data, source));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// §17.1 WEB_PUSH composition point.
|
||||
//
|
||||
// The Web Push runtime is a handler factory inside this one entry, never a
|
||||
// second registration. It is not wired here because the template cannot supply
|
||||
// the two product-owned inputs it needs: a PushAssociationFenceStore over the
|
||||
// product push control repository, and a WebPushNotificationRegistry of exact
|
||||
// notification types with the same-origin routes their clicks may open
|
||||
// (§21.11). Selecting WEB_PUSH means adding, inside a
|
||||
// `handlers.includes("WEB_PUSH")` guard: import
|
||||
// createWebPushServiceWorkerRuntime and createServiceWorkerScopeHost from the
|
||||
// sibling web-push adapter, then call the runtime with the scope host built
|
||||
// from `self` plus the product fence store and notification registry.
|
||||
//
|
||||
// Keeping the import out of the baseline entry is also what lets the realtime
|
||||
// and Web Push runtime be removed as a pure file deletion (§24.12).
|
||||
|
||||
if (handlers.includes("OFFLINE_SYNC_WAKEUP")) {
|
||||
// §19.18. Wake-up only: the handler records that a sync fired and notifies
|
||||
// controlled clients. It never sends an authenticated command (§19.20).
|
||||
self.addEventListener("sync", (rawEvent: Event) => {
|
||||
const event = rawEvent as ExtendableEvent & { tag?: string };
|
||||
if (event.tag !== OFFLINE_SYNC_TAG) return;
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window" }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
client.postMessage({
|
||||
protocolVersion: 1,
|
||||
kind: "SYNC_WAKE_OBSERVED",
|
||||
messageId: crypto.randomUUID(),
|
||||
sourceBuildId: identity.buildId,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
isOwnedStaticCacheName,
|
||||
staticCacheName,
|
||||
type ServiceWorkerHandlerId,
|
||||
type ServiceWorkerProtocolIdentity,
|
||||
type StaticAssetManifestV1,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
import {
|
||||
createServiceWorkerMessage,
|
||||
parseServiceWorkerMessage,
|
||||
} from "./service-worker-protocol.ts";
|
||||
import {
|
||||
classifyFetch,
|
||||
installStaticAssets,
|
||||
selectCachesToDelete,
|
||||
} from "./service-worker-static-assets.ts";
|
||||
|
||||
/**
|
||||
* §17.9–§17.15. Worker-side lifecycle, expressed against structural types so it
|
||||
* can be unit-tested outside a real Service Worker global and compiled under
|
||||
* `tsconfig.service-worker.json` without pulling in DOM globals.
|
||||
*/
|
||||
|
||||
export type WorkerClientLike = Readonly<{
|
||||
id: string;
|
||||
url: string;
|
||||
postMessage(message: unknown): void;
|
||||
}>;
|
||||
|
||||
export type WorkerScopeLike = Readonly<{
|
||||
caches: Readonly<{
|
||||
open(cacheName: string): Promise<Cache>;
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
match(request: string): Promise<Response | undefined>;
|
||||
}>;
|
||||
clients: Readonly<{
|
||||
matchAll(
|
||||
options?: Readonly<{
|
||||
type?: "window";
|
||||
includeUncontrolled?: boolean;
|
||||
}>,
|
||||
): Promise<
|
||||
readonly WorkerClientLike[]
|
||||
>;
|
||||
}>;
|
||||
registrationScope: string;
|
||||
skipWaiting(): Promise<void>;
|
||||
fetcher: typeof fetch;
|
||||
digest(bytes: Uint8Array): Promise<string>;
|
||||
}>;
|
||||
|
||||
export type WorkerRuntimeConfig = Readonly<{
|
||||
identity: ServiceWorkerProtocolIdentity;
|
||||
handlers: readonly ServiceWorkerHandlerId[];
|
||||
manifest: StaticAssetManifestV1 | null;
|
||||
runtimeConfigUrl: string;
|
||||
releaseManifestUrl: string;
|
||||
}>;
|
||||
|
||||
const ACTIVATION_MARKER_URL =
|
||||
"https://clean-architecture.invalid/__service-worker-activation-v1__";
|
||||
const ACTIVATION_MARKER_MAX_BYTES = 256;
|
||||
|
||||
type ActivationMarker = Readonly<{
|
||||
cacheName: string;
|
||||
activationSequence: number;
|
||||
}>;
|
||||
|
||||
export function createServiceWorkerRuntime(
|
||||
scope: WorkerScopeLike,
|
||||
config: WorkerRuntimeConfig,
|
||||
) {
|
||||
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
|
||||
const manifestUrls = new Set(
|
||||
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
|
||||
);
|
||||
const consumedNonces = new Set<string>();
|
||||
type PendingActivation = Readonly<{
|
||||
requesterBuildId: string;
|
||||
expectedClientIds: ReadonlySet<string>;
|
||||
acknowledgedClientIds: Set<string>;
|
||||
resolve(drained: boolean): void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>;
|
||||
const pendingActivations = new Map<string, PendingActivation>();
|
||||
|
||||
/**
|
||||
* §17.9. Without the static asset handler the install step opens zero caches;
|
||||
* it only registers lifecycle, push and sync handlers.
|
||||
*/
|
||||
async function onInstall(): Promise<void> {
|
||||
if (!staticEnabled || !config.manifest) return;
|
||||
const outcome = await installStaticAssets(config.manifest, {
|
||||
caches: scope.caches,
|
||||
fetcher: scope.fetcher,
|
||||
digest: scope.digest,
|
||||
});
|
||||
if (outcome.kind === "REJECTED") {
|
||||
throw new Error(`STATIC_INSTALL_REJECTED:${outcome.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.15. Delete only owned caches outside the current and one previous
|
||||
* revision. `clients.claim()` is never called (§17.12).
|
||||
*/
|
||||
async function onActivate(): Promise<number> {
|
||||
if (!staticEnabled || !config.manifest) return 0;
|
||||
const current = staticCacheName(config.manifest.setDigest);
|
||||
const names = await scope.caches.keys();
|
||||
const owned = names.filter(isOwnedStaticCacheName);
|
||||
const currentCache = await scope.caches.open(current);
|
||||
const oldCaches = owned.filter((name) => name !== current);
|
||||
const markers: ActivationMarker[] = [];
|
||||
for (const name of oldCaches) {
|
||||
const marker = await readActivationMarker(await scope.caches.open(name), name);
|
||||
if (marker) markers.push(marker);
|
||||
}
|
||||
const currentMarker = await readActivationMarker(currentCache, current);
|
||||
const highestOld = markers.reduce<ActivationMarker | null>(
|
||||
(highest, marker) =>
|
||||
!highest || marker.activationSequence >= highest.activationSequence
|
||||
? marker
|
||||
: highest,
|
||||
null,
|
||||
);
|
||||
const previous = highestOld?.cacheName ?? oldCaches.at(-1) ?? null;
|
||||
if (
|
||||
!currentMarker ||
|
||||
currentMarker.activationSequence < (highestOld?.activationSequence ?? 0)
|
||||
) {
|
||||
const nextSequence = (highestOld?.activationSequence ?? 0) + 1;
|
||||
if (!Number.isSafeInteger(nextSequence)) {
|
||||
throw new Error("SERVICE_WORKER_ACTIVATION_SEQUENCE_EXHAUSTED");
|
||||
}
|
||||
await currentCache.put(
|
||||
ACTIVATION_MARKER_URL,
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
cacheName: current,
|
||||
activationSequence: nextSequence,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
const stale = selectCachesToDelete(names, current, previous);
|
||||
let deleted = 0;
|
||||
for (const name of stale) {
|
||||
if (await scope.caches.delete(name)) deleted += 1;
|
||||
}
|
||||
void SERVICE_WORKER_BOUNDS.retainedPreviousCaches;
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* §18.5–§18.7. A verified cache hit is returned; anything else goes to the
|
||||
* network and is never written back into the active cache at runtime.
|
||||
*/
|
||||
async function onFetch(
|
||||
request: Readonly<{ method: string; url: string; mode?: string }>,
|
||||
): Promise<Response | null> {
|
||||
const classification = classifyFetch({
|
||||
method: request.method,
|
||||
requestUrl: request.url,
|
||||
isNavigation: request.mode === "navigate",
|
||||
runtimeConfigUrl: config.runtimeConfigUrl,
|
||||
releaseManifestUrl: config.releaseManifestUrl,
|
||||
manifestUrls,
|
||||
});
|
||||
if (classification !== "VERIFIED_CACHE_FIRST") return null;
|
||||
|
||||
const cached = await scope.caches.match(request.url);
|
||||
if (!cached) return null;
|
||||
if (cached.status !== 200 || cached.type === "opaque") {
|
||||
// §18.6. An invalid hit is deleted and treated as a release mismatch.
|
||||
const current = config.manifest
|
||||
? staticCacheName(config.manifest.setDigest)
|
||||
: null;
|
||||
if (current) {
|
||||
const cache = await scope.caches.open(current);
|
||||
await cache.delete(request.url).catch(() => false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.11. The waiting worker validates the request, drains every controlled
|
||||
* client, and only then calls `skipWaiting()`.
|
||||
*/
|
||||
async function onActivateRequest(
|
||||
data: unknown,
|
||||
): Promise<"ACCEPTED" | "REJECTED" | "IGNORED"> {
|
||||
const parsed = parseServiceWorkerMessage(data);
|
||||
if (!parsed.ok || parsed.message.kind !== "ACTIVATE_REQUEST") return "IGNORED";
|
||||
const nonce = parsed.message.nonce;
|
||||
if (!nonce || consumedNonces.has(nonce)) return "REJECTED";
|
||||
if (
|
||||
parsed.message.targetBuildId !== undefined &&
|
||||
parsed.message.targetBuildId !== config.identity.buildId
|
||||
) {
|
||||
return "REJECTED";
|
||||
}
|
||||
consumedNonces.add(nonce);
|
||||
if (consumedNonces.size > 64) {
|
||||
const oldest = consumedNonces.values().next().value;
|
||||
if (oldest !== undefined) consumedNonces.delete(oldest);
|
||||
}
|
||||
|
||||
const candidates = await scope.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
const clients = candidates.filter((client) =>
|
||||
isClientWithinRegistrationScope(client.url, scope.registrationScope),
|
||||
);
|
||||
const drained = await drainClients(
|
||||
clients,
|
||||
nonce,
|
||||
parsed.message.sourceBuildId,
|
||||
);
|
||||
if (!drained) {
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REJECTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return "REJECTED";
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_ACCEPTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await scope.skipWaiting();
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATED_RELOAD_REQUIRED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return "ACCEPTED";
|
||||
}
|
||||
|
||||
async function drainClients(
|
||||
clients: readonly WorkerClientLike[],
|
||||
nonce: string,
|
||||
requesterBuildId: string,
|
||||
): Promise<boolean> {
|
||||
if (clients.length === 0) return false;
|
||||
const drained = new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingActivations.delete(nonce);
|
||||
resolve(false);
|
||||
}, SERVICE_WORKER_BOUNDS.clientDrainMs);
|
||||
pendingActivations.set(
|
||||
nonce,
|
||||
Object.freeze({
|
||||
requesterBuildId,
|
||||
expectedClientIds: new Set(clients.map((client) => client.id)),
|
||||
acknowledgedClientIds: new Set<string>(),
|
||||
resolve,
|
||||
timer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: requesterBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return drained;
|
||||
}
|
||||
|
||||
function onClientMessage(data: unknown, sourceClientId: string): void {
|
||||
const parsed = parseServiceWorkerMessage(data);
|
||||
if (!parsed.ok || !parsed.message.nonce) return;
|
||||
if (
|
||||
parsed.message.targetBuildId !== config.identity.buildId ||
|
||||
(parsed.message.kind !== "CLIENT_DRAINED" &&
|
||||
parsed.message.kind !== "ACTIVATE_REJECTED")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pending = pendingActivations.get(parsed.message.nonce);
|
||||
if (
|
||||
!pending ||
|
||||
parsed.message.sourceBuildId !== pending.requesterBuildId ||
|
||||
!pending.expectedClientIds.has(sourceClientId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (parsed.message.kind === "ACTIVATE_REJECTED") {
|
||||
settlePendingActivation(parsed.message.nonce, pending, false);
|
||||
return;
|
||||
}
|
||||
pending.acknowledgedClientIds.add(sourceClientId);
|
||||
if (
|
||||
pending.acknowledgedClientIds.size === pending.expectedClientIds.size
|
||||
) {
|
||||
settlePendingActivation(parsed.message.nonce, pending, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCacheResetRequest(
|
||||
data: unknown,
|
||||
source: WorkerClientLike,
|
||||
): Promise<void> {
|
||||
const parsed = parseServiceWorkerMessage(data);
|
||||
if (
|
||||
!parsed.ok ||
|
||||
parsed.message.kind !== "CACHE_RESET_REQUEST" ||
|
||||
!parsed.message.nonce ||
|
||||
(parsed.message.targetBuildId !== undefined &&
|
||||
parsed.message.targetBuildId !== config.identity.buildId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let cachesDeleted = 0;
|
||||
const names = await scope.caches.keys();
|
||||
for (const name of names) {
|
||||
if (!name.startsWith("ca-static-v1-")) continue;
|
||||
try {
|
||||
if (await scope.caches.delete(name)) cachesDeleted += 1;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
source.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CACHE_RESET_RESULT",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce: parsed.message.nonce,
|
||||
cachesDeleted,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
onInstall,
|
||||
onActivate,
|
||||
onFetch,
|
||||
onActivateRequest,
|
||||
onClientMessage,
|
||||
onCacheResetRequest,
|
||||
manifestUrls: manifestUrls as ReadonlySet<string>,
|
||||
});
|
||||
|
||||
function settlePendingActivation(
|
||||
nonce: string,
|
||||
pending: PendingActivation,
|
||||
drained: boolean,
|
||||
): void {
|
||||
clearTimeout(pending.timer);
|
||||
pendingActivations.delete(nonce);
|
||||
pending.resolve(drained);
|
||||
}
|
||||
}
|
||||
|
||||
function isClientWithinRegistrationScope(
|
||||
clientUrl: string,
|
||||
registrationScope: string,
|
||||
): boolean {
|
||||
try {
|
||||
const client = new URL(clientUrl);
|
||||
const scope = new URL(registrationScope);
|
||||
return client.origin === scope.origin && client.href.startsWith(scope.href);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readActivationMarker(
|
||||
cache: Cache,
|
||||
expectedCacheName: string,
|
||||
): Promise<ActivationMarker | null> {
|
||||
try {
|
||||
const response = await cache.match(ACTIVATION_MARKER_URL);
|
||||
if (!response || response.status !== 200) return null;
|
||||
const declaredLength = response.headers.get("content-length");
|
||||
if (
|
||||
declaredLength !== null &&
|
||||
(!/^\d+$/u.test(declaredLength) ||
|
||||
Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
|
||||
(value as { cacheName?: unknown }).cacheName !== expectedCacheName ||
|
||||
!Number.isSafeInteger(
|
||||
(value as { activationSequence?: unknown }).activationSequence,
|
||||
) ||
|
||||
((value as { activationSequence: number }).activationSequence ?? 0) < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
cacheName: expectedCacheName,
|
||||
activationSequence: (value as { activationSequence: number })
|
||||
.activationSequence,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
type InstalledServiceWorkerSelection,
|
||||
type ServiceWorkerActivationOutcome,
|
||||
type ServiceWorkerResetOutcome,
|
||||
type ServiceWorkerRuntimeHost,
|
||||
type ServiceWorkerStartOutcome,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
import {
|
||||
createNonceRegistry,
|
||||
createServiceWorkerMessage,
|
||||
parseServiceWorkerMessage,
|
||||
} from "./service-worker-protocol.ts";
|
||||
import {
|
||||
expectedServiceWorkerUrls,
|
||||
isOwnedRegistration,
|
||||
purgeOwnedResources,
|
||||
removeOwnedRegistration,
|
||||
} from "./service-worker-removal.ts";
|
||||
|
||||
/**
|
||||
* §17.5–§17.16. The page-side controller.
|
||||
*
|
||||
* Registration happens after Runtime Config, release and contract set have all
|
||||
* validated and the first React effect has committed. The controller never
|
||||
* calls `skipWaiting()` blindly and never calls `clients.claim()`.
|
||||
*/
|
||||
|
||||
export type ActivationBlocker = () => boolean;
|
||||
|
||||
export type PageControllerDependencies = Readonly<{
|
||||
selection: InstalledServiceWorkerSelection | null;
|
||||
/** True when static selection is ACTIVE but Runtime Config disabled it. */
|
||||
disabledCleanup: boolean;
|
||||
routerBasePath: string;
|
||||
origin: string;
|
||||
buildId: string;
|
||||
container?: ServiceWorkerContainer;
|
||||
caches?: CacheStorage;
|
||||
/** §17.10. Any blocker returning true rejects automatic activation. */
|
||||
blockers?: readonly ActivationBlocker[];
|
||||
now?: () => number;
|
||||
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
|
||||
}>;
|
||||
|
||||
export function createServiceWorkerPageController(
|
||||
dependencies: PageControllerDependencies,
|
||||
): ServiceWorkerRuntimeHost {
|
||||
const nonces = createNonceRegistry();
|
||||
const now = dependencies.now ?? (() => Date.now());
|
||||
const urls = expectedServiceWorkerUrls(
|
||||
dependencies.routerBasePath,
|
||||
dependencies.origin,
|
||||
);
|
||||
|
||||
let registrationPromise: Promise<ServiceWorkerRegistration> | null = null;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
let updateTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let stopped = false;
|
||||
const pendingStops = new Set<() => void>();
|
||||
|
||||
const observe = (event: string, outcome: string) =>
|
||||
dependencies.observe?.({ event, outcome });
|
||||
|
||||
function isBlocked(): boolean {
|
||||
for (const blocker of dependencies.blockers ?? []) {
|
||||
try {
|
||||
if (blocker()) return true;
|
||||
} catch {
|
||||
// A defective blocker is treated as blocking: never activate on doubt.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function start(): Promise<ServiceWorkerStartOutcome> {
|
||||
if (stopped) return failed("STOPPED");
|
||||
const container = dependencies.container;
|
||||
|
||||
// §3.6 / §17.6. Static ACTIVE plus runtime DISABLED performs exactly one
|
||||
// owned-registration lookup and at most one unregister. No new register, no
|
||||
// cache deletion, no message or update timer.
|
||||
if (dependencies.disabledCleanup) {
|
||||
if (!container) return Object.freeze({ kind: "DISABLED" as const });
|
||||
const outcome = await removeOwnedRegistration({
|
||||
container,
|
||||
routerBasePath: dependencies.routerBasePath,
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("disable_cleanup", outcome.kind);
|
||||
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
}
|
||||
|
||||
const selection = dependencies.selection;
|
||||
// §3.6 / §17.3 `null`: zero registration lookups and zero Cache Storage
|
||||
// access. The controller must not even probe.
|
||||
if (!selection) return Object.freeze({ kind: "DISABLED" as const });
|
||||
|
||||
if (!container) return Object.freeze({ kind: "INCOMPATIBLE" as const });
|
||||
|
||||
if (selection.mode === "REMOVE_REGISTRATION") {
|
||||
const outcome = await removeOwnedRegistration({
|
||||
container,
|
||||
routerBasePath: dependencies.routerBasePath,
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("remove_registration", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
}
|
||||
if (selection.mode === "PURGE_OWNED_RESOURCES") {
|
||||
const outcome = await purgeOwnedResources({
|
||||
container,
|
||||
...(dependencies.caches ? { caches: dependencies.caches } : {}),
|
||||
routerBasePath: dependencies.routerBasePath,
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("purge_owned_resources", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
}
|
||||
|
||||
// §17.5. StrictMode's repeated effect returns the same in-flight promise
|
||||
// instead of issuing a second registration.
|
||||
registrationPromise ??= container.register(urls.scriptHref, {
|
||||
scope: urls.scopePath,
|
||||
type: "module",
|
||||
updateViaCache: "none",
|
||||
});
|
||||
|
||||
let installedRegistration: ServiceWorkerRegistration;
|
||||
try {
|
||||
installedRegistration = await registrationPromise;
|
||||
} catch {
|
||||
registrationPromise = null;
|
||||
observe("register", "FAILED");
|
||||
return failed("REGISTRATION_FAILED");
|
||||
}
|
||||
if (stopped) return failed("STOPPED");
|
||||
registration = installedRegistration;
|
||||
|
||||
if (
|
||||
!isOwnedRegistration({
|
||||
registration,
|
||||
expectedScopeHref: urls.scopeHref,
|
||||
expectedScriptHref: urls.scriptHref,
|
||||
})
|
||||
) {
|
||||
observe("register", "OWNERSHIP_MISMATCH");
|
||||
return Object.freeze({ kind: "INCOMPATIBLE" as const });
|
||||
}
|
||||
|
||||
attachMessageListener(container);
|
||||
scheduleUpdateChecks();
|
||||
|
||||
if (registration.waiting) {
|
||||
observe("register", "UPDATE_WAITING");
|
||||
return Object.freeze({ kind: "UPDATE_WAITING" as const });
|
||||
}
|
||||
// §17.13. Without `clients.claim()` the first install leaves this page
|
||||
// uncontrolled. That is reported, never silently reloaded.
|
||||
if (registration.active && !container.controller) {
|
||||
observe("register", "RELOAD_TO_ENABLE");
|
||||
return Object.freeze({ kind: "RELOAD_TO_ENABLE" as const });
|
||||
}
|
||||
observe("register", "ACTIVE");
|
||||
return Object.freeze({
|
||||
kind: "ACTIVE" as const,
|
||||
buildId: dependencies.buildId,
|
||||
});
|
||||
}
|
||||
|
||||
function attachMessageListener(container: ServiceWorkerContainer): void {
|
||||
if (messageListener) return;
|
||||
messageListener = (event: MessageEvent) => {
|
||||
if (event.origin && event.origin !== dependencies.origin) return;
|
||||
const parsed = parseServiceWorkerMessage(event.data);
|
||||
if (!parsed.ok) {
|
||||
observe("message", parsed.code);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.targetBuildId !== undefined &&
|
||||
parsed.message.targetBuildId !== dependencies.buildId
|
||||
) {
|
||||
observe("message", "TARGET_BUILD_MISMATCH");
|
||||
return;
|
||||
}
|
||||
if (parsed.message.kind === "CLIENT_DRAIN_REQUEST") {
|
||||
const nonce = parsed.message.nonce;
|
||||
const source = event.source;
|
||||
if (!nonce || !canPostMessage(source)) {
|
||||
observe("client_drain", "MALFORMED");
|
||||
return;
|
||||
}
|
||||
const rejected = isBlocked();
|
||||
source.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: rejected ? "ACTIVATE_REJECTED" : "CLIENT_DRAINED",
|
||||
sourceBuildId: dependencies.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
observe("client_drain", rejected ? "BLOCKED" : "DRAINED");
|
||||
return;
|
||||
}
|
||||
observe("message", parsed.message.kind);
|
||||
};
|
||||
container.addEventListener("message", messageListener);
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(): void {
|
||||
// §17.14. At most one check per 6 hours, and none while the page is hidden.
|
||||
if (updateTimer) return;
|
||||
updateTimer = setInterval(() => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
||||
return;
|
||||
}
|
||||
void registration?.update().catch(() => {
|
||||
// A failed update check never fails a product flow.
|
||||
});
|
||||
}, SERVICE_WORKER_BOUNDS.updateCheckIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.11. Activation is a handshake: every controlled client must close new
|
||||
* admission and acknowledge within 30s. One missing client rejects it.
|
||||
*/
|
||||
async function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
const waiting = registration?.waiting;
|
||||
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
|
||||
if (isBlocked()) {
|
||||
observe("activation", "BLOCKED_DIRTY_CLIENT");
|
||||
return Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const });
|
||||
}
|
||||
|
||||
const nonce = nonces.issue();
|
||||
const deadline = now() + SERVICE_WORKER_BOUNDS.clientDrainMs;
|
||||
const accepted = await new Promise<ServiceWorkerActivationOutcome>(
|
||||
(resolve) => {
|
||||
const container = dependencies.container;
|
||||
if (!container) {
|
||||
resolve(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (outcome: ServiceWorkerActivationOutcome) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
nonces.consume(nonce);
|
||||
clearTimeout(timer);
|
||||
container.removeEventListener("message", onMessage);
|
||||
pendingStops.delete(onStop);
|
||||
resolve(outcome);
|
||||
};
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const parsed = parseServiceWorkerMessage(event.data);
|
||||
if (!parsed.ok) return;
|
||||
if (
|
||||
parsed.message.targetBuildId !== undefined &&
|
||||
parsed.message.targetBuildId !== dependencies.buildId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "ACTIVATE_REJECTED" &&
|
||||
parsed.message.nonce === nonce
|
||||
) {
|
||||
finish(Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "ACTIVATED_RELOAD_REQUIRED" &&
|
||||
parsed.message.nonce === nonce
|
||||
) {
|
||||
finish(
|
||||
Object.freeze({ kind: "ACTIVATED_RELOAD_REQUIRED" as const }),
|
||||
);
|
||||
}
|
||||
};
|
||||
const onStop = () =>
|
||||
finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" }));
|
||||
const timer = setTimeout(
|
||||
() => finish(Object.freeze({ kind: "CLIENT_DRAIN_TIMEOUT" as const })),
|
||||
Math.max(0, deadline - now()),
|
||||
);
|
||||
pendingStops.add(onStop);
|
||||
container.addEventListener("message", onMessage);
|
||||
try {
|
||||
waiting.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REQUEST",
|
||||
sourceBuildId: dependencies.buildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
finish(Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }));
|
||||
}
|
||||
},
|
||||
);
|
||||
observe("activation", accepted.kind);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
/** §18.10. Static caches only; the registration itself is left in place. */
|
||||
async function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
||||
const container = dependencies.container;
|
||||
if (!container?.controller) {
|
||||
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
|
||||
}
|
||||
const nonce = nonces.issue();
|
||||
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (outcome: ServiceWorkerResetOutcome) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
nonces.consume(nonce);
|
||||
clearTimeout(timer);
|
||||
container.removeEventListener("message", onMessage);
|
||||
pendingStops.delete(onStop);
|
||||
resolve(outcome);
|
||||
};
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.origin && event.origin !== dependencies.origin) return;
|
||||
const parsed = parseServiceWorkerMessage(event.data);
|
||||
if (
|
||||
!parsed.ok ||
|
||||
parsed.message.kind !== "CACHE_RESET_RESULT" ||
|
||||
parsed.message.targetBuildId !== dependencies.buildId ||
|
||||
parsed.message.nonce !== nonce
|
||||
) {
|
||||
return;
|
||||
}
|
||||
finish(
|
||||
Object.freeze({
|
||||
kind: "RESET" as const,
|
||||
cachesDeleted: parsed.message.cachesDeleted ?? 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
const onStop = () =>
|
||||
finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" }));
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
Object.freeze({ kind: "FAILED" as const, code: "RESET_TIMEOUT" }),
|
||||
),
|
||||
SERVICE_WORKER_BOUNDS.clientDrainMs,
|
||||
);
|
||||
pendingStops.add(onStop);
|
||||
container.addEventListener("message", onMessage);
|
||||
try {
|
||||
container.controller?.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CACHE_RESET_REQUEST",
|
||||
sourceBuildId: dependencies.buildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
observe("cache_reset", "REQUESTED");
|
||||
} catch {
|
||||
finish(
|
||||
Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** §17.16. Ordinary shutdown removes listeners and timers; it never unregisters. */
|
||||
async function stop(): Promise<void> {
|
||||
stopped = true;
|
||||
for (const stopPending of [...pendingStops]) stopPending();
|
||||
pendingStops.clear();
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
updateTimer = null;
|
||||
}
|
||||
if (messageListener && dependencies.container) {
|
||||
dependencies.container.removeEventListener("message", messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
nonces.clear();
|
||||
}
|
||||
|
||||
return Object.freeze({ start, requestActivation, resetOwnedCaches, stop });
|
||||
}
|
||||
|
||||
function canPostMessage(
|
||||
source: MessageEventSource | null,
|
||||
): source is MessageEventSource & { postMessage(message: unknown): void } {
|
||||
return !!source && typeof source.postMessage === "function";
|
||||
}
|
||||
|
||||
function failed(code: string): ServiceWorkerStartOutcome {
|
||||
return Object.freeze({ kind: "FAILED" as const, code });
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
SERVICE_WORKER_PROTOCOL_VERSION,
|
||||
type ServiceWorkerMessage,
|
||||
type ServiceWorkerMessageKind,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
|
||||
/**
|
||||
* §17.8. Message protocol shared by the page controller and the worker entry.
|
||||
*
|
||||
* Only structural types are used here: this module is compiled into both the
|
||||
* DOM realm and the WebWorker realm, so it must not reference a global from
|
||||
* either one.
|
||||
*/
|
||||
|
||||
const MESSAGE_KINDS: ReadonlySet<string> = new Set<ServiceWorkerMessageKind>([
|
||||
"PAGE_HELLO",
|
||||
"WORKER_HELLO_ACK",
|
||||
"UPDATE_READY",
|
||||
"ACTIVATE_REQUEST",
|
||||
"ACTIVATE_ACCEPTED",
|
||||
"ACTIVATE_REJECTED",
|
||||
"CLIENT_DRAIN_REQUEST",
|
||||
"CLIENT_DRAINED",
|
||||
"ACTIVATED_RELOAD_REQUIRED",
|
||||
"CACHE_RESET_REQUEST",
|
||||
"CACHE_RESET_RESULT",
|
||||
"SYNC_WAKE_OBSERVED",
|
||||
]);
|
||||
|
||||
const ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
|
||||
export type ParsedMessage =
|
||||
| Readonly<{ ok: true; message: ServiceWorkerMessage }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Exact key set, exact protocol version, bounded identifiers. Anything else is
|
||||
* rejected rather than partially interpreted: a postMessage payload is an
|
||||
* untrusted runtime input (§21.1).
|
||||
*/
|
||||
export function parseServiceWorkerMessage(value: unknown): ParsedMessage {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const allowed = new Set([
|
||||
"protocolVersion",
|
||||
"kind",
|
||||
"messageId",
|
||||
"sourceBuildId",
|
||||
"targetBuildId",
|
||||
"nonce",
|
||||
"cachesDeleted",
|
||||
]);
|
||||
for (const key of Object.keys(candidate)) {
|
||||
if (!allowed.has(key)) return reject("MALFORMED");
|
||||
}
|
||||
if (candidate.protocolVersion !== SERVICE_WORKER_PROTOCOL_VERSION) {
|
||||
return reject("PROTOCOL_MISMATCH");
|
||||
}
|
||||
if (typeof candidate.kind !== "string" || !MESSAGE_KINDS.has(candidate.kind)) {
|
||||
return reject("UNKNOWN_KIND");
|
||||
}
|
||||
if (
|
||||
typeof candidate.messageId !== "string" ||
|
||||
!ID.test(candidate.messageId) ||
|
||||
typeof candidate.sourceBuildId !== "string" ||
|
||||
!ID.test(candidate.sourceBuildId)
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
if (
|
||||
candidate.cachesDeleted !== undefined &&
|
||||
(!Number.isSafeInteger(candidate.cachesDeleted) ||
|
||||
(candidate.cachesDeleted as number) < 0 ||
|
||||
(candidate.cachesDeleted as number) > 1_024)
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
if (
|
||||
(candidate.kind === "CACHE_RESET_RESULT") !==
|
||||
(candidate.cachesDeleted !== undefined)
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
if (
|
||||
candidate.targetBuildId !== undefined &&
|
||||
(typeof candidate.targetBuildId !== "string" ||
|
||||
!ID.test(candidate.targetBuildId))
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
if (
|
||||
candidate.nonce !== undefined &&
|
||||
(typeof candidate.nonce !== "string" || !ID.test(candidate.nonce))
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
message: Object.freeze({
|
||||
protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION,
|
||||
kind: candidate.kind as ServiceWorkerMessageKind,
|
||||
messageId: candidate.messageId,
|
||||
sourceBuildId: candidate.sourceBuildId,
|
||||
...(candidate.targetBuildId === undefined
|
||||
? {}
|
||||
: { targetBuildId: candidate.targetBuildId }),
|
||||
...(candidate.nonce === undefined ? {} : { nonce: candidate.nonce }),
|
||||
...(candidate.cachesDeleted === undefined
|
||||
? {}
|
||||
: { cachesDeleted: candidate.cachesDeleted as number }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createServiceWorkerMessage(
|
||||
input: Readonly<{
|
||||
kind: ServiceWorkerMessageKind;
|
||||
sourceBuildId: string;
|
||||
targetBuildId?: string;
|
||||
nonce?: string;
|
||||
cachesDeleted?: number;
|
||||
messageId?: string;
|
||||
}>,
|
||||
): ServiceWorkerMessage {
|
||||
return Object.freeze({
|
||||
protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION,
|
||||
kind: input.kind,
|
||||
messageId: input.messageId ?? randomId(),
|
||||
sourceBuildId: input.sourceBuildId,
|
||||
...(input.targetBuildId === undefined
|
||||
? {}
|
||||
: { targetBuildId: input.targetBuildId }),
|
||||
...(input.nonce === undefined ? {} : { nonce: input.nonce }),
|
||||
...(input.cachesDeleted === undefined
|
||||
? {}
|
||||
: { cachesDeleted: input.cachesDeleted }),
|
||||
});
|
||||
}
|
||||
|
||||
/** One-time nonce store. A nonce is consumed on first match and never reused. */
|
||||
export function createNonceRegistry(maximumEntries = 32) {
|
||||
const nonces = new Set<string>();
|
||||
return Object.freeze({
|
||||
issue(): string {
|
||||
if (nonces.size >= maximumEntries) {
|
||||
const oldest = nonces.values().next().value;
|
||||
if (oldest !== undefined) nonces.delete(oldest);
|
||||
}
|
||||
const nonce = randomId();
|
||||
nonces.add(nonce);
|
||||
return nonce;
|
||||
},
|
||||
consume(nonce: string | undefined): boolean {
|
||||
if (!nonce || !nonces.has(nonce)) return false;
|
||||
nonces.delete(nonce);
|
||||
return true;
|
||||
},
|
||||
clear(): void {
|
||||
nonces.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function reject(
|
||||
code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND",
|
||||
): ParsedMessage {
|
||||
return Object.freeze({ ok: false as const, code });
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
isOwnedStaticCacheName,
|
||||
SERVICE_WORKER_SCRIPT_PATH,
|
||||
type ServiceWorkerRemovalOutcome,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
|
||||
/**
|
||||
* §17.4 / §17.17. Exact ownership check and staged removal.
|
||||
*
|
||||
* A registration is only ours when the scope matches exactly and every present
|
||||
* worker's script URL is same-origin, with at least one matching the expected
|
||||
* script and none pointing anywhere else. A scope-prefix guess is never enough:
|
||||
* a foreign registration must never be unregistered.
|
||||
*/
|
||||
|
||||
export type ServiceWorkerContainerLike = Readonly<{
|
||||
getRegistration(
|
||||
clientUrl?: string,
|
||||
): Promise<ServiceWorkerRegistration | undefined>;
|
||||
}>;
|
||||
|
||||
export type CacheStorageLike = Readonly<{
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
}>;
|
||||
|
||||
export type OwnershipInput = Readonly<{
|
||||
registration: ServiceWorkerRegistration;
|
||||
expectedScopeHref: string;
|
||||
expectedScriptHref: string;
|
||||
}>;
|
||||
|
||||
export function isOwnedRegistration(input: OwnershipInput): boolean {
|
||||
const { registration, expectedScopeHref, expectedScriptHref } = input;
|
||||
if (registration.scope !== expectedScopeHref) return false;
|
||||
|
||||
const expectedOrigin = new URL(expectedScriptHref).origin;
|
||||
const present = [
|
||||
registration.installing,
|
||||
registration.waiting,
|
||||
registration.active,
|
||||
].filter((worker): worker is ServiceWorker => worker !== null);
|
||||
if (present.length === 0) return false;
|
||||
|
||||
let matched = false;
|
||||
for (const worker of present) {
|
||||
let scriptOrigin: string;
|
||||
try {
|
||||
scriptOrigin = new URL(worker.scriptURL).origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (scriptOrigin !== expectedOrigin) return false;
|
||||
if (worker.scriptURL === expectedScriptHref) {
|
||||
matched = true;
|
||||
} else {
|
||||
// A present worker running a different script means this registration is
|
||||
// not exclusively ours.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
export function expectedServiceWorkerUrls(
|
||||
routerBasePath: string,
|
||||
origin: string,
|
||||
): Readonly<{ scopeHref: string; scriptHref: string; scopePath: string }> {
|
||||
const scope = new URL(routerBasePath, origin);
|
||||
const script = new URL(SERVICE_WORKER_SCRIPT_PATH, scope);
|
||||
return Object.freeze({
|
||||
scopeHref: scope.href,
|
||||
scriptHref: script.href,
|
||||
scopePath: scope.pathname,
|
||||
});
|
||||
}
|
||||
|
||||
export type RemovalDependencies = Readonly<{
|
||||
container: ServiceWorkerContainerLike;
|
||||
caches?: CacheStorageLike;
|
||||
routerBasePath: string;
|
||||
origin: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* `REMOVE_REGISTRATION`: unregister only, caches retained so a rollback within
|
||||
* the retention window still finds its verified assets.
|
||||
*/
|
||||
export async function removeOwnedRegistration(
|
||||
dependencies: RemovalDependencies,
|
||||
): Promise<ServiceWorkerRemovalOutcome> {
|
||||
const urls = expectedServiceWorkerUrls(
|
||||
dependencies.routerBasePath,
|
||||
dependencies.origin,
|
||||
);
|
||||
let registration: ServiceWorkerRegistration | undefined;
|
||||
try {
|
||||
registration = await dependencies.container.getRegistration(urls.scopePath);
|
||||
} catch {
|
||||
return Object.freeze({ kind: "FAILED" as const, operation: "LOOKUP" as const });
|
||||
}
|
||||
if (!registration) return Object.freeze({ kind: "ABSENT" as const });
|
||||
if (
|
||||
!isOwnedRegistration({
|
||||
registration,
|
||||
expectedScopeHref: urls.scopeHref,
|
||||
expectedScriptHref: urls.scriptHref,
|
||||
})
|
||||
) {
|
||||
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
|
||||
}
|
||||
try {
|
||||
await registration.unregister();
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "UNREGISTER" as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ kind: "UNREGISTERED" as const });
|
||||
}
|
||||
|
||||
/**
|
||||
* `PURGE_OWNED_RESOURCES`: repeat the unregister check, then delete only caches
|
||||
* whose name parses as ours. Outbox, OPFS and user file data are untouched, and
|
||||
* unregistering is never confused with cache deletion.
|
||||
*/
|
||||
export async function purgeOwnedResources(
|
||||
dependencies: RemovalDependencies,
|
||||
): Promise<ServiceWorkerRemovalOutcome> {
|
||||
const removal = await removeOwnedRegistration(dependencies);
|
||||
if (removal.kind === "OWNERSHIP_MISMATCH" || removal.kind === "FAILED") {
|
||||
return removal;
|
||||
}
|
||||
|
||||
const cacheStorage = dependencies.caches;
|
||||
if (!cacheStorage) {
|
||||
return Object.freeze({
|
||||
kind: "PURGED" as const,
|
||||
cachesDeleted: 0,
|
||||
metadataDeleted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let names: readonly string[];
|
||||
try {
|
||||
names = await cacheStorage.keys();
|
||||
} catch {
|
||||
return Object.freeze({ kind: "FAILED" as const, operation: "PURGE" as const });
|
||||
}
|
||||
|
||||
let cachesDeleted = 0;
|
||||
for (const name of names) {
|
||||
if (!isOwnedStaticCacheName(name)) continue;
|
||||
try {
|
||||
if (await cacheStorage.delete(name)) cachesDeleted += 1;
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "PURGE" as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "PURGED" as const,
|
||||
cachesDeleted,
|
||||
metadataDeleted: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
isOwnedStaticCacheName,
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
staticCacheName,
|
||||
type StaticAssetManifestV1,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
|
||||
/**
|
||||
* §17.9 / §18. Static asset install and fetch classification.
|
||||
*
|
||||
* Only immutable hashed build assets are cached, all-or-nothing, verified at
|
||||
* install time. Navigation, runtime config, the release manifest and every API
|
||||
* response are network-only, and no runtime response is ever written into the
|
||||
* active cache.
|
||||
*/
|
||||
|
||||
export type FetchClassification =
|
||||
| "NETWORK_PASSTHROUGH"
|
||||
| "NETWORK_ONLY"
|
||||
| "VERIFIED_CACHE_FIRST";
|
||||
|
||||
export type ClassificationInput = Readonly<{
|
||||
method: string;
|
||||
requestUrl: string;
|
||||
isNavigation: boolean;
|
||||
runtimeConfigUrl: string;
|
||||
releaseManifestUrl: string;
|
||||
manifestUrls: ReadonlySet<string>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §18.5. Order matters: the exact static hit is evaluated before the generic
|
||||
* network passthrough, because an API base may legitimately be `/`.
|
||||
*/
|
||||
export function classifyFetch(input: ClassificationInput): FetchClassification {
|
||||
if (input.method !== "GET") return "NETWORK_PASSTHROUGH";
|
||||
if (input.isNavigation) return "NETWORK_ONLY";
|
||||
if (
|
||||
sameResource(input.requestUrl, input.runtimeConfigUrl) ||
|
||||
sameResource(input.requestUrl, input.releaseManifestUrl)
|
||||
) {
|
||||
return "NETWORK_ONLY";
|
||||
}
|
||||
if (input.manifestUrls.has(input.requestUrl)) return "VERIFIED_CACHE_FIRST";
|
||||
return "NETWORK_PASSTHROUGH";
|
||||
}
|
||||
|
||||
function sameResource(left: string, right: string): boolean {
|
||||
try {
|
||||
const a = new URL(left);
|
||||
const b = new URL(right, left);
|
||||
return a.origin === b.origin && a.pathname === b.pathname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallOutcome =
|
||||
| Readonly<{ kind: "INSTALLED"; cacheName: string; assets: number }>
|
||||
| Readonly<{
|
||||
kind: "REJECTED";
|
||||
code:
|
||||
| "MANIFEST_INVALID"
|
||||
| "ASSET_COUNT_EXCEEDED"
|
||||
| "ASSET_TOO_LARGE"
|
||||
| "ASSET_SET_TOO_LARGE"
|
||||
| "INSTALL_DEADLINE_EXCEEDED"
|
||||
| "FETCH_FAILED"
|
||||
| "STATUS_INVALID"
|
||||
| "CONTENT_TYPE_INVALID"
|
||||
| "BYTES_MISMATCH"
|
||||
| "INTEGRITY_MISMATCH"
|
||||
| "QUOTA_EXCEEDED";
|
||||
}>;
|
||||
|
||||
export type InstallDependencies = Readonly<{
|
||||
caches: Readonly<{
|
||||
open(cacheName: string): Promise<Cache>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
}>;
|
||||
fetcher: typeof fetch;
|
||||
digest(bytes: Uint8Array): Promise<string>;
|
||||
}>;
|
||||
|
||||
export function validateStaticAssetManifest(
|
||||
manifest: StaticAssetManifestV1,
|
||||
): InstallOutcome | null {
|
||||
const bounds = SERVICE_WORKER_BOUNDS;
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
!/^sha256:[0-9a-f]{64}$/.test(manifest.setDigest)
|
||||
) {
|
||||
return rejected("MANIFEST_INVALID");
|
||||
}
|
||||
if (manifest.assets.length > bounds.assets) {
|
||||
return rejected("ASSET_COUNT_EXCEEDED");
|
||||
}
|
||||
let total = 0;
|
||||
for (const asset of manifest.assets) {
|
||||
if (
|
||||
!asset.url ||
|
||||
!/^sha256:[0-9a-f]{64}$/.test(asset.sha256) ||
|
||||
!Number.isSafeInteger(asset.bytes) ||
|
||||
asset.bytes < 0
|
||||
) {
|
||||
return rejected("MANIFEST_INVALID");
|
||||
}
|
||||
if (asset.bytes > bounds.singleAssetBytes) return rejected("ASSET_TOO_LARGE");
|
||||
total += asset.bytes;
|
||||
}
|
||||
if (total > bounds.assetSetBytes) return rejected("ASSET_SET_TOO_LARGE");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.9. A partial candidate is never used: any failure deletes the candidate
|
||||
* cache and rejects install, leaving the previous verified revision in place.
|
||||
*/
|
||||
export async function installStaticAssets(
|
||||
manifest: StaticAssetManifestV1,
|
||||
dependencies: InstallDependencies,
|
||||
): Promise<InstallOutcome> {
|
||||
const invalid = validateStaticAssetManifest(manifest);
|
||||
if (invalid) return invalid;
|
||||
|
||||
const cacheName = staticCacheName(manifest.setDigest);
|
||||
const abortController = new AbortController();
|
||||
let deadlineExceeded = false;
|
||||
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<InstallOutcome>((resolve) => {
|
||||
deadlineTimer = setTimeout(() => {
|
||||
deadlineExceeded = true;
|
||||
abortController.abort();
|
||||
resolve(rejected("INSTALL_DEADLINE_EXCEEDED"));
|
||||
}, SERVICE_WORKER_BOUNDS.installDeadlineMs);
|
||||
});
|
||||
|
||||
const installation = installCandidate(
|
||||
manifest,
|
||||
cacheName,
|
||||
dependencies,
|
||||
abortController,
|
||||
);
|
||||
const raced = await Promise.race([installation, deadline]);
|
||||
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
||||
const outcome = deadlineExceeded
|
||||
? rejected("INSTALL_DEADLINE_EXCEEDED")
|
||||
: raced;
|
||||
|
||||
if (outcome.kind === "REJECTED") {
|
||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function installCandidate(
|
||||
manifest: StaticAssetManifestV1,
|
||||
cacheName: string,
|
||||
dependencies: InstallDependencies,
|
||||
abortController: AbortController,
|
||||
): Promise<InstallOutcome> {
|
||||
const signal = abortController.signal;
|
||||
let cache: Cache;
|
||||
try {
|
||||
cache = await dependencies.caches.open(cacheName);
|
||||
} catch {
|
||||
return rejected("QUOTA_EXCEEDED");
|
||||
}
|
||||
|
||||
const queue = [...manifest.assets];
|
||||
let failure: InstallOutcome | null = null;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (failure) return;
|
||||
const asset = queue.shift();
|
||||
if (!asset) return;
|
||||
const outcome = await storeAsset(asset, cache, dependencies, signal);
|
||||
if (outcome) {
|
||||
failure ??= outcome;
|
||||
abortController.abort();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: SERVICE_WORKER_BOUNDS.fetchConcurrency }, worker),
|
||||
);
|
||||
|
||||
if (failure) return failure;
|
||||
return Object.freeze({
|
||||
kind: "INSTALLED" as const,
|
||||
cacheName,
|
||||
assets: manifest.assets.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function storeAsset(
|
||||
asset: StaticAssetManifestV1["assets"][number],
|
||||
cache: Cache,
|
||||
dependencies: InstallDependencies,
|
||||
signal: AbortSignal,
|
||||
): Promise<InstallOutcome | null> {
|
||||
if (signal.aborted) return rejected("FETCH_FAILED");
|
||||
let response: Response;
|
||||
try {
|
||||
const fetched = await abortable(
|
||||
dependencies.fetcher(asset.url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
if (fetched === ABORTED) return rejected("FETCH_FAILED");
|
||||
response = fetched;
|
||||
} catch {
|
||||
return rejected("FETCH_FAILED");
|
||||
}
|
||||
if (response.status !== 200 || response.type === "opaque") {
|
||||
return rejected("STATUS_INVALID");
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (
|
||||
contentType.split(";", 1)[0]?.trim().toLowerCase() !==
|
||||
asset.contentType.toLowerCase()
|
||||
) {
|
||||
return rejected("CONTENT_TYPE_INVALID");
|
||||
}
|
||||
|
||||
const body = await readBoundedBody(response, asset.bytes, signal);
|
||||
if (!body.ok) return rejected(body.code);
|
||||
const bytes = body.bytes;
|
||||
|
||||
const digest = await abortable(dependencies.digest(bytes), signal);
|
||||
if (digest === ABORTED) return rejected("FETCH_FAILED");
|
||||
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
|
||||
|
||||
try {
|
||||
if (signal.aborted) return rejected("FETCH_FAILED");
|
||||
await cache.put(
|
||||
asset.url,
|
||||
new Response(bytes.slice(), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return rejected("QUOTA_EXCEEDED");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ABORTED = Symbol("service-worker-install-aborted");
|
||||
|
||||
async function abortable<Value>(
|
||||
operation: Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
): Promise<Value | typeof ABORTED> {
|
||||
if (signal.aborted) return ABORTED;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<typeof ABORTED>((resolve) => {
|
||||
onAbort = () => resolve(ABORTED);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, aborted]);
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedBody(
|
||||
response: Response,
|
||||
expectedBytes: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<
|
||||
| Readonly<{ ok: true; bytes: Uint8Array }>
|
||||
| Readonly<{ ok: false; code: "BYTES_MISMATCH" | "FETCH_FAILED" }>
|
||||
> {
|
||||
const declaredLength = response.headers.get("content-length");
|
||||
if (
|
||||
declaredLength !== null &&
|
||||
/^\d+$/u.test(declaredLength) &&
|
||||
Number(declaredLength) !== expectedBytes
|
||||
) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
|
||||
}
|
||||
if (!response.body) {
|
||||
return expectedBytes === 0
|
||||
? Object.freeze({ ok: true as const, bytes: new Uint8Array() })
|
||||
: Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const result = await abortable(reader.read(), signal);
|
||||
if (result === ABORTED) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const });
|
||||
}
|
||||
if (result.done) break;
|
||||
total += result.value.byteLength;
|
||||
if (total > expectedBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "BYTES_MISMATCH" as const,
|
||||
});
|
||||
}
|
||||
chunks.push(result.value);
|
||||
}
|
||||
} catch {
|
||||
return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const });
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (total !== expectedBytes) {
|
||||
return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
|
||||
}
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return Object.freeze({ ok: true as const, bytes });
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.15. Keep the current revision plus exactly one previous verified cache.
|
||||
* A cache found outside the owned prefix is left alone; a cache holding config,
|
||||
* manifest or API data is a security violation and is deleted.
|
||||
*/
|
||||
export function selectCachesToDelete(
|
||||
names: readonly string[],
|
||||
currentCacheName: string,
|
||||
previousCacheName: string | null,
|
||||
): readonly string[] {
|
||||
return Object.freeze(
|
||||
names.filter(
|
||||
(name) =>
|
||||
isOwnedStaticCacheName(name) &&
|
||||
name !== currentCacheName &&
|
||||
name !== previousCacheName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function rejected(code: Extract<InstallOutcome, { kind: "REJECTED" }>["code"]) {
|
||||
return Object.freeze({ kind: "REJECTED" as const, code });
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import {
|
||||
getStorageDefinition,
|
||||
isStorageValueAllowed,
|
||||
type StorageDefinition,
|
||||
} from "../../contracts/storage-keys.ts";
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type {
|
||||
StorageMutationResult,
|
||||
StoragePort,
|
||||
} from "../../application/ports/storage-port.ts";
|
||||
import {
|
||||
assertValidBrowserStorageByteLimit,
|
||||
decodeBrowserStorageEnvelope,
|
||||
DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES,
|
||||
encodeBrowserStorageEnvelope,
|
||||
type BrowserStorageCodecFailure,
|
||||
} from "./browser-storage-codec.ts";
|
||||
|
||||
export type BrowserStorageDependencies = Readonly<{
|
||||
localStorage?: Storage;
|
||||
sessionStorage?: Storage;
|
||||
now?: () => number;
|
||||
diagnostics?: DiagnosticsPort;
|
||||
maxSerializedBytes?: number;
|
||||
resolveDefinition?: (logicalName: string) => StorageDefinition;
|
||||
}>;
|
||||
|
||||
type StorageFailureCause =
|
||||
| "QUOTA_EXCEEDED"
|
||||
| "SIZE_LIMIT_EXCEEDED"
|
||||
| "UNAVAILABLE"
|
||||
| "VALUE_REJECTED";
|
||||
|
||||
export function createBrowserStorageAdapter(
|
||||
dependencies: BrowserStorageDependencies = {},
|
||||
): StoragePort {
|
||||
const memoryOverlay = new Map<string, string>();
|
||||
const suppressedPersistentValues = new Set<string>();
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const maxSerializedBytes =
|
||||
dependencies.maxSerializedBytes ??
|
||||
DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES;
|
||||
const resolveDefinition =
|
||||
dependencies.resolveDefinition ?? getStorageDefinition;
|
||||
assertValidBrowserStorageByteLimit(maxSerializedBytes);
|
||||
|
||||
function backendFor(name: string): Storage | undefined {
|
||||
if (name === "localStorage") return dependencies.localStorage;
|
||||
if (name === "sessionStorage") return dependencies.sessionStorage;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function definitionFor(
|
||||
logicalName: string,
|
||||
phase: string,
|
||||
):
|
||||
| Readonly<{ ok: true; value: StorageDefinition }>
|
||||
| Extract<StorageMutationResult, { ok: false }> {
|
||||
try {
|
||||
return { ok: true, value: resolveDefinition(logicalName) };
|
||||
} catch {
|
||||
return unavailable(phase, logicalName, dependencies.diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
function currentTime(
|
||||
phase: string,
|
||||
logicalName: string,
|
||||
):
|
||||
| Readonly<{ ok: true; value: number }>
|
||||
| Extract<StorageMutationResult, { ok: false }> {
|
||||
try {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError("Invalid storage clock.");
|
||||
}
|
||||
return { ok: true, value };
|
||||
} catch {
|
||||
return unavailable(phase, logicalName, dependencies.diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
function discardRecord(
|
||||
definition: StorageDefinition,
|
||||
backend: Storage | undefined,
|
||||
): void {
|
||||
memoryOverlay.delete(definition.physicalKey);
|
||||
suppressedPersistentValues.add(definition.physicalKey);
|
||||
if (!backend) {
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
backend.removeItem(definition.physicalKey);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
} catch {
|
||||
// Keep the in-memory tombstone so the rejected value is not parsed again.
|
||||
}
|
||||
}
|
||||
|
||||
function readEnvelope(
|
||||
raw: string,
|
||||
definition: StorageDefinition,
|
||||
backend: Storage | undefined,
|
||||
logicalName: string,
|
||||
) {
|
||||
const decoded = decodeBrowserStorageEnvelope(raw, maxSerializedBytes);
|
||||
if (!decoded.ok) {
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"discard",
|
||||
logicalName,
|
||||
codecFailureCause(decoded.reason),
|
||||
);
|
||||
discardRecord(definition, backend);
|
||||
return { ok: true as const, value: undefined };
|
||||
}
|
||||
const envelope = decoded.value;
|
||||
const expectsExpiry = typeof definition.ttl === "number";
|
||||
if (
|
||||
envelope.schemaVersion !== definition.schemaVersion ||
|
||||
expectsExpiry !== (envelope.expiresAt !== null) ||
|
||||
!isStorageValueAllowed(definition, envelope.value)
|
||||
) {
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"discard",
|
||||
logicalName,
|
||||
"VALUE_REJECTED",
|
||||
);
|
||||
discardRecord(definition, backend);
|
||||
return { ok: true as const, value: undefined };
|
||||
}
|
||||
if (envelope.expiresAt !== null) {
|
||||
const timestamp = currentTime("read", logicalName);
|
||||
if (!timestamp.ok) return timestamp;
|
||||
if (envelope.expiresAt <= timestamp.value) {
|
||||
discardRecord(definition, backend);
|
||||
return { ok: true as const, value: undefined };
|
||||
}
|
||||
}
|
||||
return { ok: true as const, value: envelope.value };
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
read(logicalName) {
|
||||
const selected = definitionFor(logicalName, "read");
|
||||
if (!selected.ok) return selected;
|
||||
const definition = selected.value;
|
||||
const backend = backendFor(definition.backend);
|
||||
const overlay = memoryOverlay.get(definition.physicalKey);
|
||||
if (overlay !== undefined) {
|
||||
return readEnvelope(
|
||||
overlay,
|
||||
definition,
|
||||
backend,
|
||||
logicalName,
|
||||
);
|
||||
}
|
||||
if (suppressedPersistentValues.has(definition.physicalKey)) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = backend?.getItem(definition.physicalKey);
|
||||
if (raw === null || raw === undefined) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
return readEnvelope(raw, definition, backend, logicalName);
|
||||
} catch {
|
||||
return unavailable("read", logicalName, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
|
||||
write(logicalName, value) {
|
||||
const selected = definitionFor(logicalName, "write");
|
||||
if (!selected.ok) return selected;
|
||||
const definition = selected.value;
|
||||
if (!isStorageValueAllowed(definition, value)) {
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
"VALUE_REJECTED",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(
|
||||
"VALUE_REJECTED",
|
||||
"write",
|
||||
logicalName,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let expiresAt: number | null = null;
|
||||
if (typeof definition.ttl === "number") {
|
||||
const timestamp = currentTime("write", logicalName);
|
||||
if (!timestamp.ok) return timestamp;
|
||||
const expiration = timestamp.value + definition.ttl;
|
||||
if (!Number.isSafeInteger(expiration)) {
|
||||
return unavailable(
|
||||
"write",
|
||||
logicalName,
|
||||
dependencies.diagnostics,
|
||||
);
|
||||
}
|
||||
expiresAt = expiration;
|
||||
}
|
||||
|
||||
const encoded = encodeBrowserStorageEnvelope(
|
||||
{
|
||||
schemaVersion: definition.schemaVersion,
|
||||
expiresAt,
|
||||
value,
|
||||
},
|
||||
maxSerializedBytes,
|
||||
);
|
||||
if (!encoded.ok) {
|
||||
const cause = codecFailureCause(encoded.reason);
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
cause,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(cause, "write", logicalName),
|
||||
};
|
||||
}
|
||||
|
||||
if (definition.backend === "memory") {
|
||||
memoryOverlay.set(definition.physicalKey, encoded.value);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const backend = backendFor(definition.backend);
|
||||
try {
|
||||
if (!backend) {
|
||||
throw new DOMException("Storage unavailable", "SecurityError");
|
||||
}
|
||||
backend.setItem(definition.physicalKey, encoded.value);
|
||||
memoryOverlay.delete(definition.physicalKey);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const cause: StorageFailureCause = isQuotaError(error)
|
||||
? "QUOTA_EXCEEDED"
|
||||
: "UNAVAILABLE";
|
||||
if (definition.quotaFallback === "memory") {
|
||||
memoryOverlay.set(definition.physicalKey, encoded.value);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
cause,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(cause, "write", logicalName),
|
||||
fallback: "memory",
|
||||
};
|
||||
}
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
cause,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(cause, "write", logicalName),
|
||||
fallback: definition.quotaFallback,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
remove(logicalName) {
|
||||
const selected = definitionFor(logicalName, "remove");
|
||||
if (!selected.ok) return selected;
|
||||
const definition = selected.value;
|
||||
const backend = backendFor(definition.backend);
|
||||
|
||||
memoryOverlay.delete(definition.physicalKey);
|
||||
suppressedPersistentValues.add(definition.physicalKey);
|
||||
try {
|
||||
backend?.removeItem(definition.physicalKey);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"remove",
|
||||
logicalName,
|
||||
"UNAVAILABLE",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure("UNAVAILABLE", "remove", logicalName),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function codecFailureCause(
|
||||
reason: BrowserStorageCodecFailure,
|
||||
): StorageFailureCause {
|
||||
return reason === "OVERSIZE"
|
||||
? "SIZE_LIMIT_EXCEEDED"
|
||||
: "VALUE_REJECTED";
|
||||
}
|
||||
|
||||
function isQuotaError(error: unknown): boolean {
|
||||
try {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const name = (error as Readonly<{ name?: unknown }>).name;
|
||||
return (
|
||||
typeof name === "string" &&
|
||||
["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(name)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storageFailure(
|
||||
cause: StorageFailureCause,
|
||||
phase: string,
|
||||
logicalName: string,
|
||||
) {
|
||||
const quota = cause === "QUOTA_EXCEEDED";
|
||||
return createFailure(
|
||||
quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE",
|
||||
"STORAGE",
|
||||
0,
|
||||
{
|
||||
code: `${safeLogicalName(logicalName)}_${phase.toUpperCase()}_${cause}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
phase: string,
|
||||
logicalName: string,
|
||||
diagnostics: DiagnosticsPort | undefined,
|
||||
): Extract<StorageMutationResult, { ok: false }> {
|
||||
recordStorageFailure(
|
||||
diagnostics,
|
||||
phase,
|
||||
logicalName,
|
||||
"UNAVAILABLE",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure("UNAVAILABLE", phase, logicalName),
|
||||
};
|
||||
}
|
||||
|
||||
function recordStorageFailure(
|
||||
diagnostics: DiagnosticsPort | undefined,
|
||||
phase: string,
|
||||
logicalName: string,
|
||||
cause: StorageFailureCause,
|
||||
): void {
|
||||
try {
|
||||
diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "storage.operation.failed",
|
||||
context: {
|
||||
operation: `${phase}:${safeLogicalName(logicalName)}`,
|
||||
error_kind:
|
||||
cause === "QUOTA_EXCEEDED"
|
||||
? "STORAGE_QUOTA_EXCEEDED"
|
||||
: "STORAGE_UNAVAILABLE",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Storage behavior remains independent from diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
function safeLogicalName(logicalName: string): string {
|
||||
return /^[A-Z][A-Z0-9_]{0,63}$/u.test(logicalName)
|
||||
? logicalName
|
||||
: "UNKNOWN_KEY";
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
export const DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES = 16_384;
|
||||
|
||||
const MAX_VALUE_DEPTH = 32;
|
||||
const MAX_VALUE_NODES = 2_048;
|
||||
const FORBIDDEN_RECORD_KEYS = new Set([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
]);
|
||||
|
||||
export type BrowserStorageEnvelope = Readonly<{
|
||||
schemaVersion: number;
|
||||
expiresAt: number | null;
|
||||
value: unknown;
|
||||
}>;
|
||||
|
||||
export type BrowserStorageCodecFailure =
|
||||
| "INVALID_VALUE"
|
||||
| "MALFORMED_RECORD"
|
||||
| "OVERSIZE";
|
||||
|
||||
export type BrowserStorageCodecResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; reason: BrowserStorageCodecFailure }>;
|
||||
|
||||
/**
|
||||
* Closed JSON codec for small Web Storage values. It rejects values that JSON
|
||||
* would silently coerce or omit, accessors, exotic prototypes and unsafe
|
||||
* record keys before they can cross the persistence boundary.
|
||||
*/
|
||||
export function encodeBrowserStorageEnvelope(
|
||||
envelope: BrowserStorageEnvelope,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<string> {
|
||||
try {
|
||||
if (!validEnvelopeMetadata(envelope)) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
const valueValidation = validateStorageValue(
|
||||
envelope.value,
|
||||
maxSerializedBytes,
|
||||
);
|
||||
if (!valueValidation.ok) return valueValidation;
|
||||
const raw = JSON.stringify(envelope);
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes
|
||||
) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
return { ok: true, value: raw };
|
||||
} catch {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeBrowserStorageEnvelope(
|
||||
raw: string,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<BrowserStorageEnvelope> {
|
||||
try {
|
||||
if (serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isExactEnvelope(parsed)) {
|
||||
return { ok: false, reason: "MALFORMED_RECORD" };
|
||||
}
|
||||
const valueValidation = validateStorageValue(
|
||||
parsed.value,
|
||||
maxSerializedBytes,
|
||||
);
|
||||
if (!valueValidation.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
valueValidation.reason === "OVERSIZE"
|
||||
? "OVERSIZE"
|
||||
: "MALFORMED_RECORD",
|
||||
};
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
} catch {
|
||||
return { ok: false, reason: "MALFORMED_RECORD" };
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidBrowserStorageByteLimit(value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 64) {
|
||||
throw new TypeError(
|
||||
"Browser storage serialized byte limit must be a safe integer of at least 64.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validEnvelopeMetadata(envelope: BrowserStorageEnvelope): boolean {
|
||||
return (
|
||||
Boolean(envelope) &&
|
||||
typeof envelope === "object" &&
|
||||
Number.isSafeInteger(envelope.schemaVersion) &&
|
||||
envelope.schemaVersion > 0 &&
|
||||
(envelope.expiresAt === null ||
|
||||
(Number.isSafeInteger(envelope.expiresAt) && envelope.expiresAt >= 0))
|
||||
);
|
||||
}
|
||||
|
||||
function isExactEnvelope(value: unknown): value is BrowserStorageEnvelope {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const keys = Object.keys(value).sort();
|
||||
if (
|
||||
keys.length !== 3 ||
|
||||
keys[0] !== "expiresAt" ||
|
||||
keys[1] !== "schemaVersion" ||
|
||||
keys[2] !== "value"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return validEnvelopeMetadata(value as BrowserStorageEnvelope);
|
||||
}
|
||||
|
||||
function serializedByteLength(raw: string, limit: number): number {
|
||||
if (raw.length > limit) return limit + 1;
|
||||
return new TextEncoder().encode(raw).byteLength;
|
||||
}
|
||||
|
||||
function validateStorageValue(
|
||||
root: unknown,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<void> {
|
||||
let visited = 0;
|
||||
const ancestors = new Set<object>();
|
||||
|
||||
function visit(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
): BrowserStorageCodecResult<void> {
|
||||
visited += 1;
|
||||
if (visited > MAX_VALUE_NODES || depth > MAX_VALUE_DEPTH) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value))
|
||||
) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value.length > maxSerializedBytes) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (
|
||||
!Array.isArray(value) &&
|
||||
prototype !== Object.prototype &&
|
||||
prototype !== null
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
if (Reflect.ownKeys(value).some((key) => typeof key === "symbol")) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const childValues: unknown[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
if (
|
||||
!Number.isSafeInteger(value.length) ||
|
||||
value.length > MAX_VALUE_NODES
|
||||
) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
const descriptorKeys = Object.keys(descriptors).filter(
|
||||
(key) => key !== "length",
|
||||
);
|
||||
if (descriptorKeys.length !== value.length) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = descriptors[String(index)];
|
||||
if (
|
||||
!descriptor ||
|
||||
!descriptor.enumerable ||
|
||||
!("value" in descriptor)
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
childValues.push(descriptor.value);
|
||||
}
|
||||
} else {
|
||||
for (const [key, descriptor] of Object.entries(descriptors)) {
|
||||
if (
|
||||
key.length > maxSerializedBytes ||
|
||||
FORBIDDEN_RECORD_KEYS.has(key) ||
|
||||
!descriptor.enumerable ||
|
||||
!("value" in descriptor)
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
childValues.push(descriptor.value);
|
||||
}
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
for (const child of childValues) {
|
||||
const result = visit(child, depth + 1);
|
||||
if (!result.ok) return result;
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
return visit(root, 0);
|
||||
} catch {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
}
|
||||
@@ -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() {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export {
|
||||
createBrowserOpfsRuntime,
|
||||
inspectBrowserOpfsSupport,
|
||||
type BrowserOpfsRuntime,
|
||||
type BrowserOpfsRuntimeDependencies,
|
||||
} from "./browser-opfs-runtime.ts";
|
||||
export {
|
||||
createOpfsByteStoreAdapter,
|
||||
type OpfsByteStore,
|
||||
type OpfsByteStoreDependencies,
|
||||
type OpfsMaintenanceAuthorityConsumer,
|
||||
type OpfsMaintenanceAuthorityDecision,
|
||||
type OpfsMaintenanceAuthorityProvider,
|
||||
type OpfsMaintenanceAuthorityRequest,
|
||||
} from "./opfs-byte-store-adapter.ts";
|
||||
export {
|
||||
createIndexedDbOpfsJournal,
|
||||
opfsJournalDatabaseName,
|
||||
type IndexedDbOpfsJournal,
|
||||
type IndexedDbOpfsJournalDependencies,
|
||||
} from "./indexeddb-opfs-journal.ts";
|
||||
export {
|
||||
DEFAULT_OPFS_RUNTIME_POLICY,
|
||||
resolveOpfsRuntimePolicy,
|
||||
type OpfsRuntimePolicy,
|
||||
type OpfsSafeObservation,
|
||||
type OpfsSafeObserver,
|
||||
} from "./opfs-policy.ts";
|
||||
export {
|
||||
createOpfsWorkerGateway,
|
||||
createOwnedOpfsWorkerClient,
|
||||
type OpfsWorkerClientDependencies,
|
||||
type OpfsWorkerLike,
|
||||
type OwnedOpfsWorkerClient,
|
||||
} from "./opfs-worker-client.ts";
|
||||
export {
|
||||
createBrowserOpfsWorkerRuntime,
|
||||
createOpfsWorkerRuntime,
|
||||
createWebLockLeaseManager,
|
||||
installOpfsWorkerMessageHandler,
|
||||
startBrowserOpfsDedicatedWorker,
|
||||
type BrowserOpfsWorkerDependencies,
|
||||
type OpfsMutationLease,
|
||||
type OpfsMutationLeaseManager,
|
||||
type OpfsWorkerMessageHost,
|
||||
type OpfsWorkerRuntime,
|
||||
} from "./opfs-worker-runtime.ts";
|
||||
export type {
|
||||
OpfsWorkerGateway,
|
||||
OpfsWorkerRequest,
|
||||
OpfsWorkerRequestBody,
|
||||
OpfsWorkerResponse,
|
||||
} from "./opfs-worker-protocol.ts";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user