feat: 기능 추가 과정중
This commit is contained in:
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Creates the skeleton-owned side of an external session integration.
|
||||
* Credential acquisition and storage stay inside the supplied external owner.
|
||||
*
|
||||
* @param {{
|
||||
* readState(): import("../../application/ports/auth-session-port.js").SessionState,
|
||||
* subscribe(listener: () => void): () => void,
|
||||
* beginSignIn(returnTo?: string): Promise<void>,
|
||||
* signOut(): Promise<void>,
|
||||
* attachCredential(request: Request): Promise<Request>,
|
||||
* recoverSession(): Promise<"restored" | "no-session">,
|
||||
* notifyUnauthenticated(): void
|
||||
* }} owner
|
||||
* @returns {import("../../application/ports/auth-session-port.js").AuthSessionPort}
|
||||
*/
|
||||
export function createExternalAuthSessionAdapter(owner) {
|
||||
return Object.freeze({
|
||||
getState() {
|
||||
return owner.readState();
|
||||
},
|
||||
subscribe(listener) {
|
||||
return owner.subscribe(listener);
|
||||
},
|
||||
async beginSignIn(returnTo) {
|
||||
await owner.beginSignIn(returnTo);
|
||||
},
|
||||
async signOut() {
|
||||
await owner.signOut();
|
||||
},
|
||||
/** @param {Request} request */
|
||||
async attach(request) {
|
||||
const attached = await owner.attachCredential(request);
|
||||
if (!(attached instanceof Request)) {
|
||||
throw new TypeError("Auth owner returned an invalid request");
|
||||
}
|
||||
return attached;
|
||||
},
|
||||
async recover() {
|
||||
const result = await owner.recoverSession();
|
||||
if (result !== "restored" && result !== "no-session") {
|
||||
throw new TypeError("Auth owner returned an invalid recovery state");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onUnauthenticated() {
|
||||
owner.notifyUnauthenticated();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createAnonymousSessionAdapter() {
|
||||
return createExternalAuthSessionAdapter({
|
||||
readState: () => "unauthenticated",
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async (request) => request,
|
||||
recoverSession: async () => "no-session",
|
||||
notifyUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Local/test-only session seam. It never creates or stores credentials.
|
||||
*
|
||||
* @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState]
|
||||
*/
|
||||
export function createDemoSessionAdapter(initialState = "unauthenticated") {
|
||||
let state = initialState;
|
||||
const listeners = new Set();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** @param {import("../../application/ports/auth-session-port.js").SessionState} next */
|
||||
function setState(next) {
|
||||
state = next;
|
||||
notify();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
getState: () => state,
|
||||
/** @param {() => void} listener */
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
async beginSignIn() {
|
||||
setState("authenticated");
|
||||
},
|
||||
async signOut() {
|
||||
setState("unauthenticated");
|
||||
},
|
||||
/** @param {Request} request */
|
||||
async attach(request) {
|
||||
return request;
|
||||
},
|
||||
async recover() {
|
||||
if (state === "recovery-pending") {
|
||||
setState("authenticated");
|
||||
return /** @type {const} */ ("restored");
|
||||
}
|
||||
return /** @type {const} */ ("no-session");
|
||||
},
|
||||
onUnauthenticated() {
|
||||
setState("unauthenticated");
|
||||
},
|
||||
setState,
|
||||
});
|
||||
}
|
||||
|
||||
export function createUnavailableSessionAdapter() {
|
||||
return Object.freeze({
|
||||
getState: () => /** @type {const} */ ("integration-failed"),
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
/** @param {Request} request */
|
||||
attach: async (request) => request,
|
||||
recover: async () => /** @type {const} */ ("no-session"),
|
||||
onUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
AuthSessionPort,
|
||||
CredentialPatch,
|
||||
CredentialRequestBinding,
|
||||
SessionState,
|
||||
} from "../../application/ports/auth-session-port.ts";
|
||||
|
||||
export type ExternalSessionOwner = Readonly<{
|
||||
readState(): SessionState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
beginSignIn(returnTo?: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
attachCredential(binding: CredentialRequestBinding): Promise<CredentialPatch>;
|
||||
recoverSession(): Promise<"restored" | "no-session">;
|
||||
notifyUnauthenticated(): void;
|
||||
}>;
|
||||
|
||||
const ALLOWED_CREDENTIAL_HEADERS = new Set([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
]);
|
||||
const MAX_HEADER_VALUE_BYTES = 8_192;
|
||||
|
||||
export function validateCredentialPatch(value: unknown): CredentialPatch {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new TypeError("Auth owner returned an invalid credential patch");
|
||||
}
|
||||
const headers = (value as Record<string, unknown>).headers;
|
||||
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
||||
throw new TypeError("Auth owner returned an invalid credential patch");
|
||||
}
|
||||
const projected: Record<string, string> = {};
|
||||
for (const [name, headerValue] of Object.entries(headers)) {
|
||||
const normalizedName = name.toLowerCase();
|
||||
if (
|
||||
!ALLOWED_CREDENTIAL_HEADERS.has(normalizedName) ||
|
||||
typeof headerValue !== "string" ||
|
||||
headerValue.length === 0 ||
|
||||
new TextEncoder().encode(headerValue).byteLength > MAX_HEADER_VALUE_BYTES ||
|
||||
/[\r\n]/.test(headerValue)
|
||||
) {
|
||||
throw new TypeError("Auth owner returned a forbidden credential patch");
|
||||
}
|
||||
projected[normalizedName] = headerValue;
|
||||
}
|
||||
return Object.freeze({ headers: Object.freeze(projected) });
|
||||
}
|
||||
|
||||
export function createExternalAuthSessionAdapter(
|
||||
owner: ExternalSessionOwner,
|
||||
): AuthSessionPort {
|
||||
return Object.freeze({
|
||||
getState: () => owner.readState(),
|
||||
subscribe: (listener) => owner.subscribe(listener),
|
||||
beginSignIn: (returnTo) => owner.beginSignIn(returnTo),
|
||||
signOut: () => owner.signOut(),
|
||||
async credentialPatch(binding) {
|
||||
return validateCredentialPatch(await owner.attachCredential(binding));
|
||||
},
|
||||
async recover() {
|
||||
const result = await owner.recoverSession();
|
||||
if (result !== "restored" && result !== "no-session") {
|
||||
throw new TypeError("Auth owner returned an invalid recovery state");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onUnauthenticated: () => owner.notifyUnauthenticated(),
|
||||
});
|
||||
}
|
||||
|
||||
const EMPTY_PATCH = Object.freeze({ headers: Object.freeze({}) });
|
||||
|
||||
export function createAnonymousSessionAdapter(): AuthSessionPort {
|
||||
return createExternalAuthSessionAdapter({
|
||||
readState: () => "unauthenticated",
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async () => EMPTY_PATCH,
|
||||
recoverSession: async () => "no-session",
|
||||
notifyUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
export type DemoSessionAdapter = AuthSessionPort &
|
||||
Readonly<{ setState(next: SessionState): void }>;
|
||||
|
||||
export function createDemoSessionAdapter(
|
||||
initialState: SessionState = "unauthenticated",
|
||||
): DemoSessionAdapter {
|
||||
let state = initialState;
|
||||
const listeners = new Set<() => void>();
|
||||
const setState = (next: SessionState) => {
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return Object.freeze({
|
||||
getState: () => state,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
async beginSignIn() {
|
||||
setState("authenticated");
|
||||
},
|
||||
async signOut() {
|
||||
setState("unauthenticated");
|
||||
},
|
||||
credentialPatch: async () => EMPTY_PATCH,
|
||||
async recover() {
|
||||
if (state === "recovery-pending") {
|
||||
setState("authenticated");
|
||||
return "restored";
|
||||
}
|
||||
return "no-session";
|
||||
},
|
||||
onUnauthenticated: () => setState("unauthenticated"),
|
||||
setState,
|
||||
});
|
||||
}
|
||||
|
||||
export function createUnavailableSessionAdapter(): AuthSessionPort {
|
||||
return Object.freeze({
|
||||
getState: () => "integration-failed",
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
credentialPatch: async () => {
|
||||
throw new TypeError("External session integration is unavailable");
|
||||
},
|
||||
recover: async () => "no-session" as const,
|
||||
onUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createStorageDurabilityAdapter,
|
||||
type StorageManagerFacade,
|
||||
type StoragePressurePolicy,
|
||||
type UserActivationFacade,
|
||||
} from "./storage-manager-adapter.ts";
|
||||
@@ -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,246 @@
|
||||
import {
|
||||
isCacheInvalidationOpaqueIdentifier,
|
||||
type CacheInvalidationTopicDefinition,
|
||||
} from "../../contracts/cache-invalidation.ts";
|
||||
import {
|
||||
createBrowserCrossContextInvalidation,
|
||||
type BroadcastChannelFacade,
|
||||
type BroadcastMessageListener,
|
||||
type BrowserCrossContextInvalidation,
|
||||
type CrossContextInvalidationObservation,
|
||||
type StorageEventTargetFacade,
|
||||
type StoragePulseFacade,
|
||||
type StoragePulseListener,
|
||||
} from "./browser-cross-context-invalidation.ts";
|
||||
|
||||
export type BrowserCrossContextHostDependencies = Readonly<{
|
||||
host?: Record<string, unknown>;
|
||||
cacheEpoch: string;
|
||||
topics: readonly CacheInvalidationTopicDefinition[];
|
||||
observe?: (observation: CrossContextInvalidationObservation) => void;
|
||||
}>;
|
||||
|
||||
type NativeBroadcastChannel = Readonly<{
|
||||
postMessage(value: unknown): void;
|
||||
addEventListener(type: string, listener: (event: unknown) => void): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: (event: unknown) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
const CHANNEL_NAME = "ca-client-cache-invalidation-v1";
|
||||
const STORAGE_PULSE_KEY =
|
||||
"ca-frontend:cache-invalidation:v1:pulse";
|
||||
|
||||
/**
|
||||
* Captures native capabilities without allowing a SecurityError getter or a
|
||||
* missing random source to fail application boot.
|
||||
*/
|
||||
export function createBrowserCrossContextInvalidationFromHost(
|
||||
dependencies: BrowserCrossContextHostDependencies,
|
||||
): BrowserCrossContextInvalidation | undefined {
|
||||
const host =
|
||||
dependencies.host ??
|
||||
(globalThis as unknown as Record<string, unknown>);
|
||||
if (
|
||||
!isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const createOpaqueId = randomIdFactory(host);
|
||||
if (!createOpaqueId) return undefined;
|
||||
|
||||
const sourceId = createOpaqueId("tab");
|
||||
const sourceEpoch = createOpaqueId("page");
|
||||
if (!sourceId || !sourceEpoch) return undefined;
|
||||
|
||||
return createBrowserCrossContextInvalidation({
|
||||
channelName: CHANNEL_NAME,
|
||||
storagePulseKey: STORAGE_PULSE_KEY,
|
||||
sourceId,
|
||||
sourceEpoch,
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topics: dependencies.topics,
|
||||
createEventId: () => {
|
||||
const eventId = createOpaqueId("event");
|
||||
if (!eventId) throw new TypeError("Secure random is unavailable.");
|
||||
return eventId;
|
||||
},
|
||||
createBroadcastChannel: broadcastFactory(host),
|
||||
storage: storageFacade(host),
|
||||
storageEvents: storageEventTarget(host),
|
||||
observe: dependencies.observe,
|
||||
});
|
||||
}
|
||||
|
||||
function safeGet(
|
||||
target: Record<string, unknown>,
|
||||
property: string,
|
||||
): unknown {
|
||||
try {
|
||||
return Reflect.get(target, property);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function randomIdFactory(
|
||||
host: Record<string, unknown>,
|
||||
): ((prefix: string) => string | null) | undefined {
|
||||
const cryptoCandidate = safeGet(host, "crypto");
|
||||
if (!cryptoCandidate || typeof cryptoCandidate !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const randomUuid = safeGet(
|
||||
cryptoCandidate as Record<string, unknown>,
|
||||
"randomUUID",
|
||||
);
|
||||
if (typeof randomUuid !== "function") return undefined;
|
||||
|
||||
return (prefix) => {
|
||||
try {
|
||||
const value = Reflect.apply(randomUuid, cryptoCandidate, []);
|
||||
if (typeof value !== "string") return null;
|
||||
const candidate = `${prefix}.${value}`;
|
||||
return isCacheInvalidationOpaqueIdentifier(candidate)
|
||||
? candidate
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastFactory(
|
||||
host: Record<string, unknown>,
|
||||
):
|
||||
| ((name: string) => BroadcastChannelFacade)
|
||||
| undefined {
|
||||
const Constructor = safeGet(host, "BroadcastChannel");
|
||||
if (typeof Constructor !== "function") return undefined;
|
||||
|
||||
return (name) => {
|
||||
const candidate = Reflect.construct(Constructor, [name]) as unknown;
|
||||
if (!isNativeBroadcastChannel(candidate)) {
|
||||
throw new TypeError("BroadcastChannel is incompatible.");
|
||||
}
|
||||
const listenerBindings = new Map<
|
||||
BroadcastMessageListener,
|
||||
(event: unknown) => void
|
||||
>();
|
||||
return Object.freeze({
|
||||
postMessage(value: unknown) {
|
||||
candidate.postMessage(value);
|
||||
},
|
||||
addEventListener(
|
||||
_type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
) {
|
||||
const bound = (event: unknown) => {
|
||||
listener({
|
||||
data:
|
||||
event && typeof event === "object"
|
||||
? safeGet(
|
||||
event as Record<string, unknown>,
|
||||
"data",
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
listenerBindings.set(listener, bound);
|
||||
candidate.addEventListener("message", bound);
|
||||
},
|
||||
removeEventListener(
|
||||
_type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
) {
|
||||
const bound = listenerBindings.get(listener);
|
||||
if (!bound) return;
|
||||
listenerBindings.delete(listener);
|
||||
candidate.removeEventListener("message", bound);
|
||||
},
|
||||
close() {
|
||||
listenerBindings.clear();
|
||||
candidate.close();
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function isNativeBroadcastChannel(
|
||||
value: unknown,
|
||||
): value is NativeBroadcastChannel {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return ["postMessage", "addEventListener", "removeEventListener", "close"].every(
|
||||
(method) => typeof safeGet(candidate, method) === "function",
|
||||
);
|
||||
}
|
||||
|
||||
function storageFacade(
|
||||
host: Record<string, unknown>,
|
||||
): StoragePulseFacade | undefined {
|
||||
const candidate = safeGet(host, "localStorage");
|
||||
if (!candidate || typeof candidate !== "object") return undefined;
|
||||
const record = candidate as Record<string, unknown>;
|
||||
const setItem = safeGet(record, "setItem");
|
||||
const removeItem = safeGet(record, "removeItem");
|
||||
if (typeof setItem !== "function" || typeof removeItem !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
setItem(key, value) {
|
||||
Reflect.apply(setItem, candidate, [key, value]);
|
||||
},
|
||||
removeItem(key) {
|
||||
Reflect.apply(removeItem, candidate, [key]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function storageEventTarget(
|
||||
host: Record<string, unknown>,
|
||||
): StorageEventTargetFacade | undefined {
|
||||
const addEventListener = safeGet(host, "addEventListener");
|
||||
const removeEventListener = safeGet(host, "removeEventListener");
|
||||
if (
|
||||
typeof addEventListener !== "function" ||
|
||||
typeof removeEventListener !== "function"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const bindings = new Map<
|
||||
StoragePulseListener,
|
||||
(event: unknown) => void
|
||||
>();
|
||||
return Object.freeze({
|
||||
addEventListener(_type: "storage", listener: StoragePulseListener) {
|
||||
const bound = (event: unknown) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
listener({ key: null, newValue: null });
|
||||
return;
|
||||
}
|
||||
const record = event as Record<string, unknown>;
|
||||
const key = safeGet(record, "key");
|
||||
const newValue = safeGet(record, "newValue");
|
||||
listener({
|
||||
key: typeof key === "string" ? key : null,
|
||||
newValue: typeof newValue === "string" ? newValue : null,
|
||||
});
|
||||
};
|
||||
bindings.set(listener, bound);
|
||||
Reflect.apply(addEventListener, host, ["storage", bound]);
|
||||
},
|
||||
removeEventListener(
|
||||
_type: "storage",
|
||||
listener: StoragePulseListener,
|
||||
) {
|
||||
const bound = bindings.get(listener);
|
||||
if (!bound) return;
|
||||
bindings.delete(listener);
|
||||
Reflect.apply(removeEventListener, host, ["storage", bound]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
import {
|
||||
CACHE_INVALIDATION_PROTOCOL_VERSION,
|
||||
CACHE_INVALIDATION_WIRE_LIMITS,
|
||||
decodeCacheInvalidationWireEvent,
|
||||
isCacheInvalidationOpaqueIdentifier,
|
||||
isCacheInvalidationTopic,
|
||||
parseCacheInvalidationWireEvent,
|
||||
type CacheInvalidationParseFailureReason,
|
||||
type CacheInvalidationTopicDefinition,
|
||||
type CacheInvalidationWireEvent,
|
||||
} from "../../contracts/cache-invalidation.ts";
|
||||
|
||||
export type CrossContextInvalidationStatus =
|
||||
| "ACTIVE_BROADCAST"
|
||||
| "ACTIVE_STORAGE_FALLBACK"
|
||||
| "DEGRADED_LOCAL_ONLY"
|
||||
| "CLOSED";
|
||||
|
||||
export type CrossContextInvalidationTransport =
|
||||
| "BROADCAST"
|
||||
| "STORAGE"
|
||||
| "NONE";
|
||||
|
||||
export type CrossContextInvalidationOrdering = "NEXT" | "GAP";
|
||||
|
||||
export type CrossContextInvalidationDelivery = Readonly<{
|
||||
event: CacheInvalidationWireEvent;
|
||||
ordering: CrossContextInvalidationOrdering;
|
||||
transport: Exclude<CrossContextInvalidationTransport, "NONE">;
|
||||
}>;
|
||||
|
||||
export type CrossContextInvalidationObservationReason =
|
||||
| CacheInvalidationParseFailureReason
|
||||
| "BROADCAST_OPEN_FAILED"
|
||||
| "BROADCAST_PUBLISH_FAILED"
|
||||
| "CLOSED"
|
||||
| "DELIVERED"
|
||||
| "DUPLICATE"
|
||||
| "HANDLER_FAILED"
|
||||
| "OPENED"
|
||||
| "PUBLISHED"
|
||||
| "SELF_ECHO"
|
||||
| "SEQUENCE_EXHAUSTED"
|
||||
| "STALE"
|
||||
| "STORAGE_CLEANUP_FAILED"
|
||||
| "STORAGE_LISTENER_FAILED"
|
||||
| "STORAGE_PUBLISH_FAILED";
|
||||
|
||||
/**
|
||||
* Safe to project into diagnostics: it contains no event, topic, cache epoch,
|
||||
* source identifier, storage value or native exception.
|
||||
*/
|
||||
export type CrossContextInvalidationObservation = Readonly<{
|
||||
operation: "OPEN" | "PUBLISH" | "RECEIVE" | "CLOSE";
|
||||
outcome: "ACCEPTED" | "DEGRADED" | "DROPPED" | "FAILED";
|
||||
transport: CrossContextInvalidationTransport;
|
||||
reason: CrossContextInvalidationObservationReason;
|
||||
ordering?: CrossContextInvalidationOrdering;
|
||||
}>;
|
||||
|
||||
export type CrossContextInvalidationPublishResult =
|
||||
| Readonly<{
|
||||
ok: true;
|
||||
transport: Exclude<CrossContextInvalidationTransport, "NONE">;
|
||||
}>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
reason:
|
||||
| "CLOSED"
|
||||
| "INVALID_EVENT"
|
||||
| "SEQUENCE_EXHAUSTED"
|
||||
| "TRANSPORT_UNAVAILABLE";
|
||||
}>;
|
||||
|
||||
export type BroadcastMessageEventFacade = Readonly<{ data: unknown }>;
|
||||
export type BroadcastMessageListener = (
|
||||
event: BroadcastMessageEventFacade,
|
||||
) => void;
|
||||
|
||||
export type BroadcastChannelFacade = Readonly<{
|
||||
postMessage(value: unknown): void;
|
||||
addEventListener(
|
||||
type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
): void;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
export type StoragePulseFacade = Readonly<{
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}>;
|
||||
|
||||
export type StoragePulseEvent = Readonly<{
|
||||
key: string | null;
|
||||
newValue: string | null;
|
||||
}>;
|
||||
|
||||
export type StoragePulseListener = (event: StoragePulseEvent) => void;
|
||||
|
||||
export type StorageEventTargetFacade = Readonly<{
|
||||
addEventListener(type: "storage", listener: StoragePulseListener): void;
|
||||
removeEventListener(type: "storage", listener: StoragePulseListener): void;
|
||||
}>;
|
||||
|
||||
export type BrowserCrossContextInvalidationDependencies = Readonly<{
|
||||
channelName: string;
|
||||
storagePulseKey: string;
|
||||
sourceId: string;
|
||||
sourceEpoch: string;
|
||||
cacheEpoch: string;
|
||||
topics: readonly CacheInvalidationTopicDefinition[];
|
||||
createEventId(): string;
|
||||
createBroadcastChannel?: (name: string) => BroadcastChannelFacade;
|
||||
storage?: StoragePulseFacade;
|
||||
storageEvents?: StorageEventTargetFacade;
|
||||
nowEpochMilliseconds?: () => number;
|
||||
eventTtlMs?: number;
|
||||
dedupeCapacity?: number;
|
||||
sourceCapacity?: number;
|
||||
observe?: (observation: CrossContextInvalidationObservation) => void;
|
||||
}>;
|
||||
|
||||
export type BrowserCrossContextInvalidation = Readonly<{
|
||||
getStatus(): CrossContextInvalidationStatus;
|
||||
publish(input: {
|
||||
topic: string;
|
||||
topicVersion: number;
|
||||
}): CrossContextInvalidationPublishResult;
|
||||
subscribe(
|
||||
listener: (delivery: CrossContextInvalidationDelivery) => void,
|
||||
): () => void;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
type SeenEvent = Readonly<{ expiresAt: number }>;
|
||||
type SourceHighWatermark = Readonly<{
|
||||
sequence: number;
|
||||
expiresAt: number;
|
||||
}>;
|
||||
|
||||
const DEFAULT_EVENT_TTL_MS = 60_000;
|
||||
const DEFAULT_DEDUPE_CAPACITY = 1_024;
|
||||
const DEFAULT_SOURCE_CAPACITY = 256;
|
||||
const MAX_DEDUPE_CAPACITY = 4_096;
|
||||
const MAX_SOURCE_CAPACITY = 1_024;
|
||||
const MAX_CHANNEL_NAME_LENGTH = 128;
|
||||
const MAX_STORAGE_KEY_LENGTH = 256;
|
||||
|
||||
export function createBrowserCrossContextInvalidation(
|
||||
dependencies: BrowserCrossContextInvalidationDependencies,
|
||||
): BrowserCrossContextInvalidation {
|
||||
const topicVersions = validateConfiguration(dependencies);
|
||||
const now = dependencies.nowEpochMilliseconds ?? Date.now;
|
||||
const eventTtlMs = dependencies.eventTtlMs ?? DEFAULT_EVENT_TTL_MS;
|
||||
const dedupeCapacity =
|
||||
dependencies.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY;
|
||||
const sourceCapacity =
|
||||
dependencies.sourceCapacity ?? DEFAULT_SOURCE_CAPACITY;
|
||||
const listeners = new Set<
|
||||
(delivery: CrossContextInvalidationDelivery) => void
|
||||
>();
|
||||
const seenEvents = new Map<string, SeenEvent>();
|
||||
const sourceHighWatermarks = new Map<string, SourceHighWatermark>();
|
||||
|
||||
let closed = false;
|
||||
let sequence = 0;
|
||||
let broadcast: BroadcastChannelFacade | undefined;
|
||||
let storageListenerInstalled = false;
|
||||
let status: CrossContextInvalidationStatus = "DEGRADED_LOCAL_ONLY";
|
||||
|
||||
const receiveBroadcast: BroadcastMessageListener = (message) => {
|
||||
receive(message.data, "BROADCAST");
|
||||
};
|
||||
const receiveStorage: StoragePulseListener = (event) => {
|
||||
if (
|
||||
closed ||
|
||||
event.key !== dependencies.storagePulseKey ||
|
||||
typeof event.newValue !== "string"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parsed = decodeCacheInvalidationWireEvent(event.newValue, {
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topicVersions,
|
||||
nowEpochMilliseconds: safeNow(now),
|
||||
});
|
||||
acceptParsed(parsed, "STORAGE");
|
||||
};
|
||||
|
||||
installStorageListener();
|
||||
openBroadcast();
|
||||
refreshStatus();
|
||||
|
||||
function installStorageListener(): void {
|
||||
if (!dependencies.storageEvents) return;
|
||||
try {
|
||||
dependencies.storageEvents.addEventListener(
|
||||
"storage",
|
||||
receiveStorage,
|
||||
);
|
||||
storageListenerInstalled = true;
|
||||
} catch {
|
||||
observe({
|
||||
operation: "OPEN",
|
||||
outcome: "DEGRADED",
|
||||
transport: "STORAGE",
|
||||
reason: "STORAGE_LISTENER_FAILED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openBroadcast(): void {
|
||||
if (!dependencies.createBroadcastChannel) return;
|
||||
let candidate: BroadcastChannelFacade | undefined;
|
||||
try {
|
||||
candidate = dependencies.createBroadcastChannel(
|
||||
dependencies.channelName,
|
||||
);
|
||||
candidate.addEventListener("message", receiveBroadcast);
|
||||
broadcast = candidate;
|
||||
observe({
|
||||
operation: "OPEN",
|
||||
outcome: "ACCEPTED",
|
||||
transport: "BROADCAST",
|
||||
reason: "OPENED",
|
||||
});
|
||||
} catch {
|
||||
if (candidate) {
|
||||
try {
|
||||
candidate.removeEventListener("message", receiveBroadcast);
|
||||
} catch {
|
||||
// Opening still fails closed when listener cleanup is rejected.
|
||||
}
|
||||
try {
|
||||
candidate.close();
|
||||
} catch {
|
||||
// Opening still falls back when provider cleanup is rejected.
|
||||
}
|
||||
}
|
||||
observe({
|
||||
operation: "OPEN",
|
||||
outcome: "DEGRADED",
|
||||
transport: "BROADCAST",
|
||||
reason: "BROADCAST_OPEN_FAILED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStatus(): void {
|
||||
if (closed) {
|
||||
status = "CLOSED";
|
||||
} else if (broadcast) {
|
||||
status = "ACTIVE_BROADCAST";
|
||||
} else if (
|
||||
dependencies.storage &&
|
||||
dependencies.storageEvents &&
|
||||
storageListenerInstalled
|
||||
) {
|
||||
status = "ACTIVE_STORAGE_FALLBACK";
|
||||
} else {
|
||||
status = "DEGRADED_LOCAL_ONLY";
|
||||
}
|
||||
}
|
||||
|
||||
function publish(input: {
|
||||
topic: string;
|
||||
topicVersion: number;
|
||||
}): CrossContextInvalidationPublishResult {
|
||||
if (closed) {
|
||||
return Object.freeze({ ok: false, reason: "CLOSED" });
|
||||
}
|
||||
if (sequence >= Number.MAX_SAFE_INTEGER) {
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "FAILED",
|
||||
transport: "NONE",
|
||||
reason: "SEQUENCE_EXHAUSTED",
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: "SEQUENCE_EXHAUSTED",
|
||||
});
|
||||
}
|
||||
|
||||
const emittedAt = safeNow(now);
|
||||
let eventId: string;
|
||||
try {
|
||||
eventId = dependencies.createEventId();
|
||||
} catch {
|
||||
return invalidPublish();
|
||||
}
|
||||
const candidate: CacheInvalidationWireEvent = Object.freeze({
|
||||
protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION,
|
||||
eventId,
|
||||
sourceId: dependencies.sourceId,
|
||||
sourceEpoch: dependencies.sourceEpoch,
|
||||
sequence: sequence + 1,
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topic: input.topic,
|
||||
topicVersion: input.topicVersion,
|
||||
emittedAt,
|
||||
expiresAt: emittedAt + eventTtlMs,
|
||||
});
|
||||
const parsed = parseCacheInvalidationWireEvent(candidate, {
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topicVersions,
|
||||
nowEpochMilliseconds: emittedAt,
|
||||
});
|
||||
if (!parsed.ok) return invalidPublish();
|
||||
|
||||
sequence = candidate.sequence;
|
||||
pruneTracking(emittedAt);
|
||||
if (broadcast) {
|
||||
try {
|
||||
broadcast.postMessage(candidate);
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "ACCEPTED",
|
||||
transport: "BROADCAST",
|
||||
reason: "PUBLISHED",
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
transport: "BROADCAST",
|
||||
});
|
||||
} catch {
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "BROADCAST",
|
||||
reason: "BROADCAST_PUBLISH_FAILED",
|
||||
});
|
||||
closeBroadcast();
|
||||
refreshStatus();
|
||||
}
|
||||
}
|
||||
return publishThroughStorage(candidate);
|
||||
}
|
||||
|
||||
function invalidPublish(): CrossContextInvalidationPublishResult {
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "FAILED",
|
||||
transport: "NONE",
|
||||
reason: "INVALID_ENVELOPE",
|
||||
});
|
||||
return Object.freeze({ ok: false, reason: "INVALID_EVENT" });
|
||||
}
|
||||
|
||||
function publishThroughStorage(
|
||||
event: CacheInvalidationWireEvent,
|
||||
): CrossContextInvalidationPublishResult {
|
||||
if (
|
||||
!dependencies.storage ||
|
||||
!dependencies.storageEvents ||
|
||||
!storageListenerInstalled
|
||||
) {
|
||||
status = "DEGRADED_LOCAL_ONLY";
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "NONE",
|
||||
reason: "STORAGE_PUBLISH_FAILED",
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: "TRANSPORT_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(event);
|
||||
try {
|
||||
dependencies.storage.setItem(
|
||||
dependencies.storagePulseKey,
|
||||
serialized,
|
||||
);
|
||||
} catch {
|
||||
status = "DEGRADED_LOCAL_ONLY";
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "STORAGE",
|
||||
reason: "STORAGE_PUBLISH_FAILED",
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: "TRANSPORT_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
dependencies.storage.removeItem(dependencies.storagePulseKey);
|
||||
} catch {
|
||||
// A fixed pulse key prevents unbounded retained keys. A later publish
|
||||
// overwrites it with a unique event, so delivery succeeded even when
|
||||
// best-effort cleanup did not.
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "STORAGE",
|
||||
reason: "STORAGE_CLEANUP_FAILED",
|
||||
});
|
||||
}
|
||||
status = "ACTIVE_STORAGE_FALLBACK";
|
||||
observe({
|
||||
operation: "PUBLISH",
|
||||
outcome: "ACCEPTED",
|
||||
transport: "STORAGE",
|
||||
reason: "PUBLISHED",
|
||||
});
|
||||
return Object.freeze({ ok: true, transport: "STORAGE" });
|
||||
}
|
||||
|
||||
function receive(
|
||||
input: unknown,
|
||||
transport: Exclude<CrossContextInvalidationTransport, "NONE">,
|
||||
): void {
|
||||
if (closed) return;
|
||||
const parsed = parseCacheInvalidationWireEvent(input, {
|
||||
cacheEpoch: dependencies.cacheEpoch,
|
||||
topicVersions,
|
||||
nowEpochMilliseconds: safeNow(now),
|
||||
});
|
||||
acceptParsed(parsed, transport);
|
||||
}
|
||||
|
||||
function acceptParsed(
|
||||
parsed: ReturnType<typeof parseCacheInvalidationWireEvent>,
|
||||
transport: Exclude<CrossContextInvalidationTransport, "NONE">,
|
||||
): void {
|
||||
if (closed) return;
|
||||
if (!parsed.ok) {
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport,
|
||||
reason: parsed.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const event = parsed.value;
|
||||
if (
|
||||
event.sourceId === dependencies.sourceId &&
|
||||
event.sourceEpoch === dependencies.sourceEpoch
|
||||
) {
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport,
|
||||
reason: "SELF_ECHO",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = safeNow(now);
|
||||
pruneTracking(currentTime);
|
||||
if (seenEvents.has(event.eventId)) {
|
||||
touchSeen(event.eventId, event.expiresAt);
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport,
|
||||
reason: "DUPLICATE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
touchSeen(event.eventId, event.expiresAt);
|
||||
|
||||
const sourceKey = `${event.sourceId}\u0000${event.sourceEpoch}`;
|
||||
const previous = sourceHighWatermarks.get(sourceKey);
|
||||
if (previous && event.sequence <= previous.sequence) {
|
||||
touchSource(sourceKey, previous);
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport,
|
||||
reason: "STALE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ordering: CrossContextInvalidationOrdering =
|
||||
(!previous && event.sequence > 1) ||
|
||||
(previous !== undefined &&
|
||||
event.sequence > previous.sequence + 1)
|
||||
? "GAP"
|
||||
: "NEXT";
|
||||
touchSource(sourceKey, {
|
||||
sequence: event.sequence,
|
||||
expiresAt: event.expiresAt,
|
||||
});
|
||||
|
||||
const delivery = Object.freeze({ event, ordering, transport });
|
||||
for (const listener of [...listeners]) {
|
||||
if (closed) return;
|
||||
if (!listeners.has(listener)) continue;
|
||||
try {
|
||||
listener(delivery);
|
||||
} catch {
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "FAILED",
|
||||
transport,
|
||||
reason: "HANDLER_FAILED",
|
||||
ordering,
|
||||
});
|
||||
}
|
||||
}
|
||||
observe({
|
||||
operation: "RECEIVE",
|
||||
outcome: "ACCEPTED",
|
||||
transport,
|
||||
reason: "DELIVERED",
|
||||
ordering,
|
||||
});
|
||||
}
|
||||
|
||||
function touchSeen(eventId: string, expiresAt: number): void {
|
||||
seenEvents.delete(eventId);
|
||||
seenEvents.set(eventId, { expiresAt });
|
||||
evictOldest(seenEvents, dedupeCapacity);
|
||||
}
|
||||
|
||||
function touchSource(
|
||||
sourceKey: string,
|
||||
value: SourceHighWatermark,
|
||||
): void {
|
||||
sourceHighWatermarks.delete(sourceKey);
|
||||
sourceHighWatermarks.set(sourceKey, value);
|
||||
evictOldest(sourceHighWatermarks, sourceCapacity);
|
||||
}
|
||||
|
||||
function pruneTracking(currentTime: number): void {
|
||||
for (const [eventId, entry] of seenEvents) {
|
||||
if (entry.expiresAt <= currentTime) seenEvents.delete(eventId);
|
||||
}
|
||||
for (const [sourceKey, entry] of sourceHighWatermarks) {
|
||||
if (entry.expiresAt <= currentTime) {
|
||||
sourceHighWatermarks.delete(sourceKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
listener: (delivery: CrossContextInvalidationDelivery) => void,
|
||||
): () => void {
|
||||
if (closed) return () => {};
|
||||
listeners.add(listener);
|
||||
let subscribed = true;
|
||||
return () => {
|
||||
if (!subscribed) return;
|
||||
subscribed = false;
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function closeBroadcast(): void {
|
||||
const current = broadcast;
|
||||
broadcast = undefined;
|
||||
if (!current) return;
|
||||
try {
|
||||
current.removeEventListener("message", receiveBroadcast);
|
||||
} catch {
|
||||
// Closing continues even when a provider rejects listener removal.
|
||||
}
|
||||
try {
|
||||
current.close();
|
||||
} catch {
|
||||
// Closing is best-effort and remains idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
closeBroadcast();
|
||||
if (storageListenerInstalled && dependencies.storageEvents) {
|
||||
try {
|
||||
dependencies.storageEvents.removeEventListener(
|
||||
"storage",
|
||||
receiveStorage,
|
||||
);
|
||||
} catch {
|
||||
// Local closed state still prevents any late callback.
|
||||
}
|
||||
}
|
||||
storageListenerInstalled = false;
|
||||
listeners.clear();
|
||||
seenEvents.clear();
|
||||
sourceHighWatermarks.clear();
|
||||
status = "CLOSED";
|
||||
observe({
|
||||
operation: "CLOSE",
|
||||
outcome: "ACCEPTED",
|
||||
transport: "NONE",
|
||||
reason: "CLOSED",
|
||||
});
|
||||
}
|
||||
|
||||
function observe(
|
||||
observation: CrossContextInvalidationObservation,
|
||||
): void {
|
||||
try {
|
||||
dependencies.observe?.(Object.freeze({ ...observation }));
|
||||
} catch {
|
||||
// Transport behavior must not depend on diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
getStatus: () => status,
|
||||
publish,
|
||||
subscribe,
|
||||
close,
|
||||
});
|
||||
}
|
||||
|
||||
function validateConfiguration(
|
||||
dependencies: BrowserCrossContextInvalidationDependencies,
|
||||
): Readonly<Record<string, number>> {
|
||||
if (
|
||||
typeof dependencies.channelName !== "string" ||
|
||||
dependencies.channelName.length < 1 ||
|
||||
dependencies.channelName.length > MAX_CHANNEL_NAME_LENGTH ||
|
||||
typeof dependencies.storagePulseKey !== "string" ||
|
||||
dependencies.storagePulseKey.length < 1 ||
|
||||
dependencies.storagePulseKey.length > MAX_STORAGE_KEY_LENGTH ||
|
||||
!isCacheInvalidationOpaqueIdentifier(dependencies.sourceId) ||
|
||||
!isCacheInvalidationOpaqueIdentifier(dependencies.sourceEpoch) ||
|
||||
!isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch) ||
|
||||
typeof dependencies.createEventId !== "function"
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation configuration is invalid.",
|
||||
);
|
||||
}
|
||||
const eventTtlMs = dependencies.eventTtlMs ?? DEFAULT_EVENT_TTL_MS;
|
||||
const dedupeCapacity =
|
||||
dependencies.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY;
|
||||
const sourceCapacity =
|
||||
dependencies.sourceCapacity ?? DEFAULT_SOURCE_CAPACITY;
|
||||
if (
|
||||
!Number.isSafeInteger(eventTtlMs) ||
|
||||
eventTtlMs < 1 ||
|
||||
eventTtlMs >
|
||||
CACHE_INVALIDATION_WIRE_LIMITS.maxEventTtlMs ||
|
||||
!Number.isSafeInteger(dedupeCapacity) ||
|
||||
dedupeCapacity < 1 ||
|
||||
dedupeCapacity > MAX_DEDUPE_CAPACITY ||
|
||||
!Number.isSafeInteger(sourceCapacity) ||
|
||||
sourceCapacity < 1 ||
|
||||
sourceCapacity > MAX_SOURCE_CAPACITY
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation bounds are invalid.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(dependencies.topics)) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation topic registry is invalid.",
|
||||
);
|
||||
}
|
||||
const topicVersions: Record<string, number> = Object.create(null);
|
||||
for (const definition of dependencies.topics) {
|
||||
if (
|
||||
!definition ||
|
||||
typeof definition !== "object" ||
|
||||
!isCacheInvalidationTopic(definition.topic) ||
|
||||
!Number.isSafeInteger(definition.topicVersion) ||
|
||||
definition.topicVersion < 1 ||
|
||||
Object.hasOwn(topicVersions, definition.topic)
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation topic registry is invalid.",
|
||||
);
|
||||
}
|
||||
topicVersions[definition.topic] = definition.topicVersion;
|
||||
}
|
||||
if (Object.keys(topicVersions).length < 1) {
|
||||
throw new TypeError(
|
||||
"Cross-context invalidation requires an allowlisted topic.",
|
||||
);
|
||||
}
|
||||
return Object.freeze(topicVersions);
|
||||
}
|
||||
|
||||
function safeNow(now: () => number): number {
|
||||
try {
|
||||
const value = now();
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : -1;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function evictOldest<Value>(
|
||||
values: Map<string, Value>,
|
||||
capacity: number,
|
||||
): void {
|
||||
while (values.size > capacity) {
|
||||
const oldest = values.keys().next().value;
|
||||
if (typeof oldest !== "string") return;
|
||||
values.delete(oldest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
createBrowserCrossContextInvalidation,
|
||||
type BroadcastChannelFacade,
|
||||
type BroadcastMessageEventFacade,
|
||||
type BroadcastMessageListener,
|
||||
type BrowserCrossContextInvalidation,
|
||||
type BrowserCrossContextInvalidationDependencies,
|
||||
type CrossContextInvalidationDelivery,
|
||||
type CrossContextInvalidationObservation,
|
||||
type CrossContextInvalidationObservationReason,
|
||||
type CrossContextInvalidationOrdering,
|
||||
type CrossContextInvalidationPublishResult,
|
||||
type CrossContextInvalidationStatus,
|
||||
type CrossContextInvalidationTransport,
|
||||
type StorageEventTargetFacade,
|
||||
type StoragePulseEvent,
|
||||
type StoragePulseFacade,
|
||||
type StoragePulseListener,
|
||||
} from "./browser-cross-context-invalidation.ts";
|
||||
export {
|
||||
createBrowserCrossContextInvalidationFromHost,
|
||||
type BrowserCrossContextHostDependencies,
|
||||
} from "./browser-cross-context-host.ts";
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.js";
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import {
|
||||
projectDiagnosticRecord,
|
||||
safeErrorKind,
|
||||
type DiagnosticRecord,
|
||||
type DiagnosticRecordInput,
|
||||
} from "../../contracts/diagnostics.js";
|
||||
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
|
||||
} from "../../contracts/diagnostics.ts";
|
||||
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
|
||||
|
||||
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
|
||||
record() {},
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
import { systemClock } from "../platform/system-clock.js";
|
||||
import { getApiOperation } from "../../contracts/api-operations.js";
|
||||
import {
|
||||
createFailure as failure,
|
||||
kindForStatus as statusKind,
|
||||
normalizeUnknownFailure,
|
||||
safeValidationIssues,
|
||||
} from "../../contracts/errors.js";
|
||||
import { mapOperationPayload } from "./resource-mapper.js";
|
||||
import { retryDelay, shouldRetry } from "./retry-policy.js";
|
||||
import {
|
||||
validateEnvelope,
|
||||
validateOperationPayload,
|
||||
validateOperationRequest,
|
||||
} from "./schema-registry.js";
|
||||
import { buildRequestTarget } from "./request-builder.js";
|
||||
import {
|
||||
attemptBucket,
|
||||
durationBucket,
|
||||
statusGroup,
|
||||
} from "../../contracts/diagnostics.js";
|
||||
|
||||
const noAuthSession =
|
||||
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
|
||||
getState: () => /** @type {"unauthenticated"} */ ("unauthenticated"),
|
||||
attach: async (request) => request,
|
||||
recover: async () => /** @type {"no-session"} */ ("no-session"),
|
||||
onUnauthenticated: () => {},
|
||||
});
|
||||
|
||||
/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */
|
||||
/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* setTimeout(callback: () => void, milliseconds: number): unknown,
|
||||
* clearTimeout(handle: unknown): void
|
||||
* }} Scheduler
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ ok: true, value: unknown, meta: Record<string, string> } |
|
||||
* { ok: false, error: HttpFailure }} HttpResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* baseUrl: string,
|
||||
* fetcher?: typeof fetch,
|
||||
* authSession?: import("../../application/ports/auth-session-port.js").AuthSessionPort,
|
||||
* clock?: import("../../application/ports/clock-port.js").ClockPort,
|
||||
* random?: () => number,
|
||||
* validatePayload?: (schemaId: string, value: unknown) =>
|
||||
* { success: true, data: unknown } | { success: false },
|
||||
* validateRequest?: (schemaId: string, value: unknown) =>
|
||||
* { success: true, data: unknown } | { success: false },
|
||||
* mapPayload?: (operationId: string, payload: unknown) => unknown,
|
||||
* idempotencyKeyFactory?: () => string,
|
||||
* timeoutMs?: number,
|
||||
* maxRetryAttempts?: number,
|
||||
* scheduler?: Scheduler,
|
||||
* getOperation?: typeof getApiOperation,
|
||||
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort,
|
||||
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
|
||||
* correlationIdFactory?: () => string
|
||||
* }} dependencies
|
||||
*/
|
||||
export function createHttpClient(dependencies) {
|
||||
const fetcher = dependencies.fetcher ?? fetch;
|
||||
const authSession = dependencies.authSession ?? noAuthSession;
|
||||
const clock = dependencies.clock ?? systemClock;
|
||||
const random = dependencies.random ?? Math.random;
|
||||
const validatePayload =
|
||||
dependencies.validatePayload ?? validateOperationPayload;
|
||||
const validateRequest =
|
||||
dependencies.validateRequest ?? validateOperationRequest;
|
||||
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
|
||||
const idempotencyKeyFactory =
|
||||
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
|
||||
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
|
||||
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
|
||||
const selectOperation = dependencies.getOperation ?? getApiOperation;
|
||||
const diagnostics = dependencies.diagnostics;
|
||||
const telemetry = dependencies.telemetry;
|
||||
const correlationIdFactory =
|
||||
dependencies.correlationIdFactory ??
|
||||
(() => `request-${Math.floor(random() * 1_000_000).toString(36)}`);
|
||||
const scheduler =
|
||||
dependencies.scheduler ??
|
||||
/** @type {Scheduler} */ ({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
/** @type {ReturnType<typeof setTimeout>} */ (handle),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string | OperationRequestInput} request
|
||||
* @param {{
|
||||
* body?: unknown,
|
||||
* routeId?: string,
|
||||
* pathParams?: Record<string, string | number>,
|
||||
* searchParams?: unknown,
|
||||
* signal?: AbortSignal,
|
||||
* idempotencyKey?: string,
|
||||
* correlationId?: string
|
||||
* }} [legacyInput]
|
||||
* @returns {Promise<HttpResult>}
|
||||
*/
|
||||
async function execute(request, legacyInput = {}) {
|
||||
const input =
|
||||
typeof request === "string"
|
||||
? {
|
||||
operationId: request,
|
||||
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
|
||||
pathParams: legacyInput.pathParams,
|
||||
searchParams: legacyInput.searchParams,
|
||||
body: legacyInput.body,
|
||||
signal: legacyInput.signal,
|
||||
idempotencyKey: legacyInput.idempotencyKey,
|
||||
correlationId: legacyInput.correlationId,
|
||||
}
|
||||
: request;
|
||||
const operation = selectOperation(input.operationId);
|
||||
const startedAt = clock.now();
|
||||
const correlationId = input.correlationId ?? correlationIdFactory();
|
||||
/**
|
||||
* @param {HttpResult} outcome
|
||||
* @param {"success" | "recovered" | "failed" | "aborted"} outcomeKind
|
||||
*/
|
||||
function finalize(outcome, outcomeKind) {
|
||||
const error = outcome.ok ? undefined : outcome.error;
|
||||
const context = {
|
||||
route_id: input.routeId,
|
||||
operation_id: input.operationId,
|
||||
correlation_id: correlationId,
|
||||
outcome: outcomeKind,
|
||||
error_kind: error?.kind ?? "NONE",
|
||||
http_status_group: statusGroup(error?.httpStatus),
|
||||
attempt_count_bucket: attemptBucket(
|
||||
error?.attemptCount ?? retryCount + 1,
|
||||
),
|
||||
duration_bucket: durationBucket(clock.now() - startedAt),
|
||||
};
|
||||
try {
|
||||
diagnostics?.record({
|
||||
level: error ? "warn" : "info",
|
||||
eventId: "http.request.completed",
|
||||
context,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics cannot change the HTTP result.
|
||||
}
|
||||
if (error && outcomeKind !== "aborted") {
|
||||
try {
|
||||
telemetry?.emit("api.request.failed", {
|
||||
error_kind: context.error_kind,
|
||||
http_status_group: context.http_status_group,
|
||||
attempt_count_bucket: context.attempt_count_bucket,
|
||||
route_id: context.route_id,
|
||||
operation_id: context.operation_id,
|
||||
duration_bucket: context.duration_bucket,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry cannot change the HTTP result.
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
const logicalIdempotencyKey =
|
||||
operation.idempotency === "keyed"
|
||||
? input.idempotencyKey ?? idempotencyKeyFactory()
|
||||
: undefined;
|
||||
let retryCount = 0;
|
||||
let recoveryUsed = false;
|
||||
|
||||
while (true) {
|
||||
const attempt = retryCount;
|
||||
/** @type {HttpResult} */
|
||||
const outcome = await performAttempt({
|
||||
operation,
|
||||
input,
|
||||
attempt,
|
||||
idempotencyKey: logicalIdempotencyKey,
|
||||
});
|
||||
|
||||
if (outcome.ok) {
|
||||
return finalize(
|
||||
outcome,
|
||||
retryCount > 0 || recoveryUsed ? "recovered" : "success",
|
||||
);
|
||||
}
|
||||
|
||||
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
|
||||
recoveryUsed = true;
|
||||
const recovered = await recoverSession(
|
||||
authSession,
|
||||
operation,
|
||||
outcome.error,
|
||||
);
|
||||
if (!recovered.ok) return finalize(recovered, "failed");
|
||||
if (operation.idempotency === "none") {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
...outcome.error,
|
||||
retryable: false,
|
||||
action: "retry",
|
||||
},
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outcome.error.httpStatus === 401 && recoveryUsed) {
|
||||
authSession.onUnauthenticated();
|
||||
return finalize(outcome, "failed");
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldRetry(
|
||||
operation,
|
||||
outcome.error,
|
||||
retryCount,
|
||||
maxRetryAttempts,
|
||||
)
|
||||
) {
|
||||
return finalize(
|
||||
outcome,
|
||||
outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed",
|
||||
);
|
||||
}
|
||||
|
||||
const delay = retryDelay(outcome.error, retryCount, random, clock.now());
|
||||
retryCount += 1;
|
||||
|
||||
try {
|
||||
await clock.sleep(delay, input.signal);
|
||||
} catch {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
|
||||
code: "REQUEST_ABORTED",
|
||||
}),
|
||||
},
|
||||
"aborted",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* operation: ReturnType<typeof getApiOperation>,
|
||||
* input: OperationRequestInput,
|
||||
* attempt: number,
|
||||
* idempotencyKey?: string
|
||||
* }} context
|
||||
* @returns {Promise<HttpResult>}
|
||||
*/
|
||||
async function performAttempt(context) {
|
||||
const { operation, input, attempt, idempotencyKey } = context;
|
||||
/** @type {unknown} */
|
||||
let parsedSearch = {};
|
||||
let parsedBody;
|
||||
const requestValue =
|
||||
operation.requestSource === "search"
|
||||
? input.searchParams ?? {}
|
||||
: operation.requestSource === "body"
|
||||
? input.body
|
||||
: {};
|
||||
if (operation.requestSource !== "none") {
|
||||
const requestValidation = validateRequest(
|
||||
operation.requestSchema,
|
||||
requestValue,
|
||||
);
|
||||
if (!requestValidation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
|
||||
code: "REQUEST_SCHEMA_INVALID",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (operation.requestSource === "search") {
|
||||
parsedSearch = requestValidation.data;
|
||||
} else {
|
||||
parsedBody = requestValidation.data;
|
||||
}
|
||||
}
|
||||
|
||||
const target = buildRequestTarget(
|
||||
dependencies.baseUrl,
|
||||
operation,
|
||||
input.pathParams,
|
||||
parsedSearch,
|
||||
);
|
||||
if (!target.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
|
||||
code: target.code,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
}, operation.timeoutMs ?? defaultTimeoutMs);
|
||||
const onExternalAbort = () => controller.abort(input.signal?.reason);
|
||||
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
||||
if (input.signal?.aborted) onExternalAbort();
|
||||
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
|
||||
|
||||
let request = new Request(target.url, {
|
||||
method: operation.method,
|
||||
headers,
|
||||
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
if (operation.auth === "external-session") {
|
||||
try {
|
||||
request = await authSession.attach(request);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
|
||||
code: "AUTH_ATTACH_FAILED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetcher(request);
|
||||
return await parseResponse(
|
||||
response,
|
||||
operation,
|
||||
attempt,
|
||||
validatePayload,
|
||||
mapPayload,
|
||||
);
|
||||
} catch {
|
||||
if (timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
|
||||
code: "REQUEST_TIMEOUT",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (controller.signal.aborted || input.signal?.aborted) {
|
||||
const externalReason = input.signal?.reason;
|
||||
if (externalReason === "timeout") {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
|
||||
code: "REQUEST_TIMEOUT",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
externalReason !== undefined &&
|
||||
!["navigation", "user", "superseded"].includes(String(externalReason))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_FAILURE", operation.operationId, attempt, {
|
||||
code: "EXTERNAL_ABORT_UNRESOLVED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_ABORTED", operation.operationId, attempt, {
|
||||
code: "REQUEST_ABORTED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("NETWORK_UNREACHABLE", operation.operationId, attempt, {
|
||||
code: "NETWORK_UNREACHABLE",
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
scheduler.clearTimeout(timeout);
|
||||
input.signal?.removeEventListener("abort", onExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ execute });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
|
||||
* @param {number} attempt
|
||||
* @param {(schemaId: string, value: unknown) =>
|
||||
* { success: true, data: unknown } | { success: false }} validatePayload
|
||||
* @param {(operationId: string, payload: unknown) => unknown} mapPayload
|
||||
* @returns {Promise<HttpResult>}
|
||||
*/
|
||||
async function parseResponse(
|
||||
response,
|
||||
operation,
|
||||
attempt,
|
||||
validatePayload,
|
||||
mapPayload,
|
||||
) {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
|
||||
code: "CONTENT_TYPE_MISMATCH",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let envelope;
|
||||
try {
|
||||
envelope = await response.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("MALFORMED_JSON", operation.operationId, attempt, {
|
||||
code: "MALFORMED_JSON",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const envelopeValidation = validateEnvelope(envelope);
|
||||
if (!envelopeValidation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
response.ok ? "ENVELOPE_MISMATCH" : statusKind(response.status),
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{
|
||||
code: response.ok ? "ENVELOPE_MISMATCH" : "HTTP_FAILURE",
|
||||
httpStatus: response.status,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const envelopeRecord =
|
||||
/** @type {Record<string, unknown>} */ (envelopeValidation.data);
|
||||
if (response.ok && envelopeRecord.success === true && "data" in envelopeRecord) {
|
||||
const payload = validatePayload(operation.responseSchema, envelopeRecord.data);
|
||||
if (!payload.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("SCHEMA_MISMATCH", operation.operationId, attempt, {
|
||||
code: "SCHEMA_MISMATCH",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: mapPayload(operation.operationId, payload.data),
|
||||
meta: safeMeta(envelopeRecord.meta),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: normalizeUnknownFailure(error, {
|
||||
operationId: operation.operationId,
|
||||
attempt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const kind = statusKind(response.status);
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const backendError =
|
||||
envelopeRecord.error && typeof envelopeRecord.error === "object"
|
||||
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
|
||||
: {};
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(kind, operation.operationId, attempt, {
|
||||
code: safeBackendCode(envelope),
|
||||
httpStatus: response.status,
|
||||
requestId: safeMeta(envelopeRecord.meta).requestId,
|
||||
traceId: safeMeta(envelopeRecord.meta).traceId,
|
||||
retryAfterMs:
|
||||
response.status === 429 && retryAfter
|
||||
? parseRetryAfterHeader(retryAfter)
|
||||
: undefined,
|
||||
validationIssues:
|
||||
response.status === 422
|
||||
? safeValidationIssues(backendError.details)
|
||||
: undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../../application/ports/auth-session-port.js").AuthSessionPort} authSession
|
||||
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
|
||||
* @param {HttpFailure} originalFailure
|
||||
* @returns {Promise<{ok: true} | {ok: false, error: HttpFailure}>}
|
||||
*/
|
||||
async function recoverSession(authSession, operation, originalFailure) {
|
||||
try {
|
||||
const result = await authSession.recover();
|
||||
if (result === "restored") return { ok: true };
|
||||
if (result === "no-session") {
|
||||
authSession.onUnauthenticated();
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount, {
|
||||
code: "AUTH_REQUIRED",
|
||||
httpStatus: 401,
|
||||
}),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Normalized below.
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operation.operationId,
|
||||
originalFailure.attemptCount,
|
||||
{ code: "AUTH_RECOVERY_FAILED" },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} kind
|
||||
* @param {string} operationId
|
||||
* @param {number} attempt
|
||||
* @param {FailureDetails} [details]
|
||||
* @returns {HttpFailure}
|
||||
*/
|
||||
/** @param {unknown} envelope */
|
||||
function safeBackendCode(envelope) {
|
||||
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
|
||||
const error = /** @type {Record<string, unknown>} */ (envelope).error;
|
||||
if (!error || typeof error !== "object") return "HTTP_FAILURE";
|
||||
const code = /** @type {Record<string, unknown>} */ (error).code;
|
||||
return typeof code === "string" ? code : "HTTP_FAILURE";
|
||||
}
|
||||
|
||||
/** @param {unknown} meta @returns {Record<string, string>} */
|
||||
function safeMeta(meta) {
|
||||
if (!meta || typeof meta !== "object") return {};
|
||||
const metaRecord = /** @type {Record<string, unknown>} */ (meta);
|
||||
return {
|
||||
...(typeof metaRecord.requestId === "string"
|
||||
? { requestId: metaRecord.requestId }
|
||||
: {}),
|
||||
...(typeof metaRecord.traceId === "string" ? { traceId: metaRecord.traceId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function parseRetryAfterHeader(value) {
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : undefined;
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApiOperation } from "../../contracts/api-operations.js";
|
||||
import type { ApiOperation } from "../../contracts/api-operations.ts";
|
||||
|
||||
export type OperationRequestInput = Readonly<{
|
||||
operationId: string;
|
||||
@@ -15,7 +15,12 @@ export type RequestTargetResult =
|
||||
| Readonly<{ success: true; url: URL }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID";
|
||||
code:
|
||||
| "BASE_URL_INVALID"
|
||||
| "PATH_PARAMETER_MISSING"
|
||||
| "PATH_PARAMETER_UNEXPECTED"
|
||||
| "PATH_PARAMETER_INVALID"
|
||||
| "SEARCH_PARAMETER_INVALID";
|
||||
}>;
|
||||
|
||||
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
|
||||
@@ -26,7 +31,35 @@ export function buildRequestTarget(
|
||||
pathParams: Readonly<Record<string, string | number>> = {},
|
||||
parsedSearch: unknown = {},
|
||||
): RequestTargetResult {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(baseUrl);
|
||||
} catch {
|
||||
return { success: false, code: "BASE_URL_INVALID" };
|
||||
}
|
||||
if (
|
||||
(base.protocol !== "https:" &&
|
||||
!(
|
||||
base.protocol === "http:" &&
|
||||
["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)
|
||||
)) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
base.search ||
|
||||
base.hash
|
||||
) {
|
||||
return { success: false, code: "BASE_URL_INVALID" };
|
||||
}
|
||||
|
||||
const placeholders = new Set<string>();
|
||||
for (const match of operation.path.matchAll(pathParameterPattern)) {
|
||||
placeholders.add(match[1] ?? match[2] ?? "");
|
||||
}
|
||||
if (Object.keys(pathParams).some((key) => !placeholders.has(key))) {
|
||||
return { success: false, code: "PATH_PARAMETER_UNEXPECTED" };
|
||||
}
|
||||
let missingPathParameter = false;
|
||||
let invalidPathParameter = false;
|
||||
const pathname = operation.path.replace(
|
||||
pathParameterPattern,
|
||||
(_token, colonName: string | undefined, braceName: string | undefined) => {
|
||||
@@ -36,12 +69,27 @@ export function buildRequestTarget(
|
||||
missingPathParameter = true;
|
||||
return "";
|
||||
}
|
||||
return encodeURIComponent(String(value));
|
||||
const serialized = String(value);
|
||||
if (
|
||||
serialized.length === 0 ||
|
||||
serialized.length > 512 ||
|
||||
[...serialized].some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code < 32 || code === 127;
|
||||
})
|
||||
) {
|
||||
invalidPathParameter = true;
|
||||
return "";
|
||||
}
|
||||
return encodeURIComponent(serialized);
|
||||
},
|
||||
);
|
||||
if (missingPathParameter) {
|
||||
return { success: false, code: "PATH_PARAMETER_MISSING" };
|
||||
}
|
||||
if (invalidPathParameter) {
|
||||
return { success: false, code: "PATH_PARAMETER_INVALID" };
|
||||
}
|
||||
|
||||
if (
|
||||
parsedSearch === null ||
|
||||
@@ -51,7 +99,12 @@ export function buildRequestTarget(
|
||||
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
|
||||
}
|
||||
|
||||
const url = new URL(pathname, baseUrl);
|
||||
const basePrefix = base.pathname.endsWith("/")
|
||||
? base.pathname
|
||||
: `${base.pathname}/`;
|
||||
const relativePath = pathname.replace(/^\/+/, "");
|
||||
base.pathname = `${basePrefix}${relativePath}`.replace(/\/{2,}/g, "/");
|
||||
const url = base;
|
||||
const search = parsedSearch as Readonly<Record<string, unknown>>;
|
||||
for (const key of Object.keys(search).sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
@@ -70,5 +123,12 @@ export function buildRequestTarget(
|
||||
url.searchParams.append(key, String(item));
|
||||
}
|
||||
}
|
||||
if (
|
||||
operation.maxEncodedSearchBytes !== undefined &&
|
||||
new TextEncoder().encode(url.search).byteLength >
|
||||
operation.maxEncodedSearchBytes
|
||||
) {
|
||||
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
|
||||
}
|
||||
return { success: true, url };
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** @param {string} operationId @param {unknown} payload */
|
||||
export function mapOperationPayload(operationId, payload) {
|
||||
void payload;
|
||||
throw new TypeError(`No boundary mapper registered for ${operationId}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
mappingFailure,
|
||||
type MappingResult,
|
||||
} from "../../contracts/boundary-mapper.ts";
|
||||
|
||||
export function mapOperationPayload(
|
||||
_operationId: string,
|
||||
_payload: unknown,
|
||||
): MappingResult<never> {
|
||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
}
|
||||
@@ -1,27 +1,23 @@
|
||||
const retryKinds = new Set([
|
||||
const retryKinds: ReadonlySet<string> = new Set([
|
||||
"NETWORK_UNREACHABLE",
|
||||
"REQUEST_TIMEOUT",
|
||||
"RATE_LIMITED",
|
||||
"SERVER_FAILURE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {number} retryIndex
|
||||
* @param {() => number} [random]
|
||||
* @param {number} [baseDelayMs]
|
||||
* @param {number} [maxDelayMs]
|
||||
*/
|
||||
export function calculateBackoff(
|
||||
retryIndex,
|
||||
retryIndex: number,
|
||||
random = Math.random,
|
||||
baseDelayMs = 250,
|
||||
maxDelayMs = 2_000,
|
||||
) {
|
||||
): number {
|
||||
return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random();
|
||||
}
|
||||
|
||||
/** @param {string | null | undefined} value @param {number} [now] */
|
||||
export function parseRetryAfter(value, now = Date.now()) {
|
||||
export function parseRetryAfter(
|
||||
value: string | null | undefined,
|
||||
now = Date.now(),
|
||||
): number | null {
|
||||
if (!value) return null;
|
||||
|
||||
const seconds = Number(value);
|
||||
@@ -34,13 +30,24 @@ export function parseRetryAfter(value, now = Date.now()) {
|
||||
return Math.max(0, timestamp - now);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation
|
||||
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
|
||||
* @param {number} retryCount
|
||||
* @param {number} [maxRetries]
|
||||
*/
|
||||
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
|
||||
export type RetryOperation = Readonly<{
|
||||
idempotency: "safe" | "keyed" | "none";
|
||||
retry?: "runtime" | "never";
|
||||
}>;
|
||||
|
||||
export type RetryFailure = Readonly<{
|
||||
kind: string;
|
||||
retryAfterMs?: number;
|
||||
retryAfter?: string;
|
||||
httpStatus?: number;
|
||||
}>;
|
||||
|
||||
export function shouldRetry(
|
||||
operation: RetryOperation,
|
||||
failure: RetryFailure,
|
||||
retryCount: number,
|
||||
maxRetries = 2,
|
||||
): boolean {
|
||||
if (operation.retry === "never") return false;
|
||||
if (retryCount >= maxRetries) return false;
|
||||
if (!retryKinds.has(failure.kind)) return false;
|
||||
@@ -61,13 +68,12 @@ export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
|
||||
return operation.idempotency === "safe" || operation.idempotency === "keyed";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ kind: string, retryAfterMs?: number, retryAfter?: string }} failure
|
||||
* @param {number} retryIndex
|
||||
* @param {() => number} [random]
|
||||
* @param {number} [now]
|
||||
*/
|
||||
export function retryDelay(failure, retryIndex, random = Math.random, now = Date.now()) {
|
||||
export function retryDelay(
|
||||
failure: RetryFailure,
|
||||
retryIndex: number,
|
||||
random = Math.random,
|
||||
now = Date.now(),
|
||||
): number {
|
||||
const localBackoff = calculateBackoff(retryIndex, random);
|
||||
if (failure.kind !== "RATE_LIMITED") return localBackoff;
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const metaSchema = z
|
||||
.object({
|
||||
requestId: z.string().min(1),
|
||||
traceId: z.string().min(1),
|
||||
correlationId: z.string().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const successEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(true),
|
||||
data: z.unknown(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const failureEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(false),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string().min(1),
|
||||
category: z.string().min(1).optional(),
|
||||
message: z.string().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
details: z.unknown().optional(),
|
||||
})
|
||||
.strict(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
|
||||
successEnvelopeSchema,
|
||||
failureEnvelopeSchema,
|
||||
]);
|
||||
|
||||
const payloadSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
|
||||
|
||||
const requestSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function validateEnvelope(value) {
|
||||
return projectResult(responseEnvelopeSchema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId @param {unknown} value */
|
||||
export function validateOperationPayload(schemaId, value) {
|
||||
const schema = payloadSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId @param {unknown} value */
|
||||
export function validateOperationRequest(schemaId, value) {
|
||||
const schema = requestSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId */
|
||||
function missingSchema(schemaId) {
|
||||
return {
|
||||
success: /** @type {false} */ (false),
|
||||
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ success: true, data: unknown } |
|
||||
* { success: false, error: { issues: Array<{ path: PropertyKey[], code: string }> } }} result
|
||||
*/
|
||||
function projectResult(result) {
|
||||
if (result.success) {
|
||||
return {
|
||||
success: /** @type {true} */ (true),
|
||||
data: structuredClone(result.data),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: /** @type {false} */ (false),
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const metaSchema = z
|
||||
.object({
|
||||
requestId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/),
|
||||
traceId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/),
|
||||
correlationId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/).optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export const successEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(true),
|
||||
data: z.unknown(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const failureEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(false),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string().regex(/^[A-Z0-9_]{1,64}$/),
|
||||
category: z.string().min(1).max(64).optional(),
|
||||
message: z.string().max(1_024).optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
details: z.unknown().optional(),
|
||||
})
|
||||
.strict(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
|
||||
successEnvelopeSchema,
|
||||
failureEnvelopeSchema,
|
||||
]);
|
||||
|
||||
const payloadSchemas: Readonly<Record<string, z.ZodType<unknown>>> =
|
||||
Object.freeze({});
|
||||
|
||||
const requestSchemas: Readonly<Record<string, z.ZodType<unknown>>> =
|
||||
Object.freeze({});
|
||||
|
||||
export type SchemaIssue = Readonly<{
|
||||
path: string;
|
||||
code: string;
|
||||
schemaId?: string;
|
||||
}>;
|
||||
|
||||
export type SchemaValidationResult =
|
||||
| Readonly<{ success: true; data: unknown }>
|
||||
| Readonly<{ success: false; issues: readonly SchemaIssue[] }>;
|
||||
|
||||
export function validateEnvelope(value: unknown): SchemaValidationResult {
|
||||
return projectResult(responseEnvelopeSchema.safeParse(value));
|
||||
}
|
||||
|
||||
export function validateOperationPayload(
|
||||
schemaId: string,
|
||||
value: unknown,
|
||||
): SchemaValidationResult {
|
||||
const schema = payloadSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
export function validateOperationRequest(
|
||||
schemaId: string,
|
||||
value: unknown,
|
||||
): SchemaValidationResult {
|
||||
const schema = requestSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
function missingSchema(schemaId: string): SchemaValidationResult {
|
||||
return {
|
||||
success: false,
|
||||
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
|
||||
};
|
||||
}
|
||||
|
||||
function projectResult(
|
||||
result:
|
||||
| Readonly<{ success: true; data: unknown }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
error: Readonly<{
|
||||
issues: readonly Readonly<{
|
||||
path: readonly PropertyKey[];
|
||||
code: string;
|
||||
}>[];
|
||||
}>;
|
||||
}>,
|
||||
): SchemaValidationResult {
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: structuredClone(result.data),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import("../../application/ports/clock-port.js").ClockPort} */
|
||||
export const systemClock = Object.freeze({
|
||||
import type { ClockPort } from "../../application/ports/clock-port.ts";
|
||||
|
||||
export const systemClock: ClockPort = Object.freeze({
|
||||
now: () => Date.now(),
|
||||
sleep(milliseconds, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
|
||||
|
||||
export type ConditionalValidatorBinding = Readonly<{
|
||||
definitionId: string;
|
||||
identityToken: string;
|
||||
representationVersion: number;
|
||||
scope: CacheScopeSnapshot;
|
||||
}>;
|
||||
|
||||
export type ConditionalValidatorStore = Readonly<{
|
||||
install(
|
||||
binding: ConditionalValidatorBinding,
|
||||
validator: string,
|
||||
cacheRevision: number,
|
||||
): boolean;
|
||||
prepare(
|
||||
binding: ConditionalValidatorBinding,
|
||||
cacheRevision: number,
|
||||
): string | null;
|
||||
acceptNotModified(
|
||||
binding: ConditionalValidatorBinding,
|
||||
cacheRevision: number,
|
||||
hasMappedValue: boolean,
|
||||
): boolean;
|
||||
remove(binding: ConditionalValidatorBinding): void;
|
||||
clear(): void;
|
||||
}>;
|
||||
|
||||
type ValidatorRow = {
|
||||
validator: string;
|
||||
cacheRevision: number;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
export function createConditionalValidatorStore(
|
||||
maxEntries = 1_024,
|
||||
): ConditionalValidatorStore {
|
||||
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
||||
throw new TypeError("Invalid conditional validator capacity.");
|
||||
}
|
||||
const rows = new Map<string, ValidatorRow>();
|
||||
|
||||
function key(binding: ConditionalValidatorBinding): string | null {
|
||||
if (
|
||||
!binding.scope.isCurrent() ||
|
||||
!binding.definitionId ||
|
||||
!/^[A-Za-z0-9._:-]{16,128}$/.test(binding.identityToken) ||
|
||||
!Number.isSafeInteger(binding.representationVersion) ||
|
||||
binding.representationVersion < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
binding.scope.fingerprint,
|
||||
binding.definitionId,
|
||||
binding.identityToken,
|
||||
binding.representationVersion,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
install(binding, validator, cacheRevision) {
|
||||
const selectedKey = key(binding);
|
||||
if (
|
||||
!selectedKey ||
|
||||
!isSafeEntityTag(validator) ||
|
||||
!Number.isSafeInteger(cacheRevision) ||
|
||||
cacheRevision < 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!rows.has(selectedKey) && rows.size >= maxEntries) return false;
|
||||
rows.set(selectedKey, {
|
||||
validator,
|
||||
cacheRevision,
|
||||
generation: binding.scope.generation,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
prepare(binding, cacheRevision) {
|
||||
const selectedKey = key(binding);
|
||||
if (!selectedKey) return null;
|
||||
const row = rows.get(selectedKey);
|
||||
return row &&
|
||||
row.generation === binding.scope.generation &&
|
||||
row.cacheRevision === cacheRevision
|
||||
? row.validator
|
||||
: null;
|
||||
},
|
||||
acceptNotModified(binding, cacheRevision, hasMappedValue) {
|
||||
const selectedKey = key(binding);
|
||||
if (!selectedKey || !hasMappedValue) return false;
|
||||
const row = rows.get(selectedKey);
|
||||
return Boolean(
|
||||
row &&
|
||||
row.generation === binding.scope.generation &&
|
||||
row.cacheRevision === cacheRevision,
|
||||
);
|
||||
},
|
||||
remove(binding) {
|
||||
const selectedKey = key(binding);
|
||||
if (selectedKey) rows.delete(selectedKey);
|
||||
},
|
||||
clear() {
|
||||
rows.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isSafeEntityTag(value: string): boolean {
|
||||
if (value.length < 3 || value.length > 256) return false;
|
||||
const opaque = value.startsWith('W/"')
|
||||
? value.slice(3, -1)
|
||||
: value.startsWith('"')
|
||||
? value.slice(1, -1)
|
||||
: null;
|
||||
if (opaque === null || !value.endsWith('"')) return false;
|
||||
return [...opaque].every((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code === 0x21 || (code >= 0x23 && code <= 0x7e) ||
|
||||
(code >= 0x80 && code <= 0xff);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Result } from "../../application/result.ts";
|
||||
import type {
|
||||
CursorPage,
|
||||
CursorPaginationProfile,
|
||||
CursorPaginationRuntime,
|
||||
} from "../../contracts/cursor-pagination.ts";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
|
||||
export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
definitionId: string;
|
||||
profile: CursorPaginationProfile;
|
||||
loadPage(
|
||||
cursor: string | null,
|
||||
context: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<Result<CursorPage<Value>>>;
|
||||
}>): CursorPaginationRuntime<Value> {
|
||||
validateProfile(dependencies.profile);
|
||||
return Object.freeze({
|
||||
async loadAll(context) {
|
||||
const items: Value[] = [];
|
||||
const cursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let snapshot: string | null | undefined;
|
||||
for (
|
||||
let pageIndex = 0;
|
||||
pageIndex < dependencies.profile.maxPages;
|
||||
pageIndex += 1
|
||||
) {
|
||||
if (context.signal?.aborted) {
|
||||
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
|
||||
}
|
||||
const result = await dependencies.loadPage(cursor, context);
|
||||
if (!result.ok) return result;
|
||||
const page = result.value;
|
||||
if (!isValidPage(page, dependencies.profile)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_PAGE_INVALID",
|
||||
);
|
||||
}
|
||||
if (snapshot === undefined) {
|
||||
snapshot = page.snapshotToken;
|
||||
} else if (snapshot !== page.snapshotToken) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_SNAPSHOT_CHANGED",
|
||||
);
|
||||
}
|
||||
items.push(...page.items);
|
||||
if (
|
||||
items.length > dependencies.profile.maxTotalItems ||
|
||||
estimatedBytes(items) > dependencies.profile.maxEstimatedBytes
|
||||
) {
|
||||
return failure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
"PAGINATION_RESULT_LIMIT",
|
||||
);
|
||||
}
|
||||
if (!page.hasMore) return { ok: true, value: Object.freeze(items) };
|
||||
const nextCursor = page.nextCursor;
|
||||
if (!nextCursor || cursors.has(nextCursor)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_CURSOR_LOOP",
|
||||
);
|
||||
}
|
||||
cursors.add(nextCursor);
|
||||
cursor = nextCursor;
|
||||
}
|
||||
return failure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
"PAGINATION_PAGE_LIMIT",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function failure(
|
||||
kind:
|
||||
| "PAGINATION_CONTRACT_VIOLATION"
|
||||
| "RESULT_LIMIT_EXCEEDED"
|
||||
| "REQUEST_ABORTED",
|
||||
code: string,
|
||||
) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: createFailure(kind, dependencies.definitionId, 0, { code }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateProfile(profile: CursorPaginationProfile): void {
|
||||
if (
|
||||
!profile.profileId ||
|
||||
!Number.isSafeInteger(profile.maxPages) ||
|
||||
profile.maxPages < 1 ||
|
||||
profile.maxPages > 100 ||
|
||||
!Number.isSafeInteger(profile.maxTotalItems) ||
|
||||
profile.maxTotalItems < 1 ||
|
||||
!Number.isSafeInteger(profile.maxEstimatedBytes) ||
|
||||
profile.maxEstimatedBytes < 1 ||
|
||||
!Number.isSafeInteger(profile.maxCursorBytes) ||
|
||||
profile.maxCursorBytes < 1 ||
|
||||
profile.maxCursorBytes > 4_096
|
||||
) {
|
||||
throw new TypeError("Invalid cursor pagination profile.");
|
||||
}
|
||||
}
|
||||
|
||||
function isValidPage<Value>(
|
||||
page: CursorPage<Value>,
|
||||
profile: CursorPaginationProfile,
|
||||
): boolean {
|
||||
const encoder = new TextEncoder();
|
||||
return (
|
||||
Boolean(page) &&
|
||||
Array.isArray(page.items) &&
|
||||
typeof page.hasMore === "boolean" &&
|
||||
page.hasMore === (page.nextCursor !== null) &&
|
||||
(page.nextCursor === null ||
|
||||
(typeof page.nextCursor === "string" &&
|
||||
page.nextCursor.length > 0 &&
|
||||
encoder.encode(page.nextCursor).byteLength <=
|
||||
profile.maxCursorBytes)) &&
|
||||
(page.snapshotToken === null ||
|
||||
(typeof page.snapshotToken === "string" &&
|
||||
page.snapshotToken.length > 0 &&
|
||||
encoder.encode(page.snapshotToken).byteLength <=
|
||||
profile.maxCursorBytes)) &&
|
||||
(profile.allowSparsePage || !page.hasMore || page.items.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function estimatedBytes(value: unknown): number {
|
||||
try {
|
||||
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
|
||||
import type { QueryInvalidationCoordinator } from "../../contracts/query-invalidation.ts";
|
||||
import {
|
||||
createRuntimeIdentityRegistry,
|
||||
type RuntimeIdentityRegistry,
|
||||
} from "../../contracts/query-keys.ts";
|
||||
import type {
|
||||
CacheScopeSnapshot,
|
||||
ServerStateScopeRuntime,
|
||||
} from "../../contracts/server-state-scope.ts";
|
||||
|
||||
export function createServerStateScopeRuntime(dependencies: Readonly<{
|
||||
session: Pick<AuthSessionPort, "subscribe">;
|
||||
queryInvalidation: QueryInvalidationCoordinator;
|
||||
tokenFactory?: () => string;
|
||||
}>): ServerStateScopeRuntime {
|
||||
const listeners = new Set<() => void>();
|
||||
let generation = 1;
|
||||
let identities = newIdentityRegistry(dependencies.tokenFactory);
|
||||
let fingerprint = scopeFingerprint(dependencies.tokenFactory);
|
||||
let disposed = false;
|
||||
let resetChain = Promise.resolve();
|
||||
|
||||
function createSnapshot(): CacheScopeSnapshot {
|
||||
const capturedGeneration = generation;
|
||||
const capturedIdentities = identities;
|
||||
return Object.freeze({
|
||||
generation: capturedGeneration,
|
||||
fingerprint,
|
||||
identities: capturedIdentities,
|
||||
isCurrent: () =>
|
||||
!disposed &&
|
||||
generation === capturedGeneration &&
|
||||
identities === capturedIdentities,
|
||||
});
|
||||
}
|
||||
let currentSnapshot = createSnapshot();
|
||||
|
||||
const unsubscribe = dependencies.session.subscribe(() => {
|
||||
if (disposed) return;
|
||||
const previousIdentities = identities;
|
||||
const targetGeneration = ++generation;
|
||||
resetChain = resetChain
|
||||
.then(() => dependencies.queryInvalidation.resetLocal())
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
previousIdentities.close();
|
||||
if (disposed || generation !== targetGeneration) return;
|
||||
identities = newIdentityRegistry(dependencies.tokenFactory);
|
||||
fingerprint = scopeFingerprint(dependencies.tokenFactory);
|
||||
currentSnapshot = createSnapshot();
|
||||
for (const listener of listeners) listener();
|
||||
});
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
getSnapshot: () => currentSnapshot,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
listeners.clear();
|
||||
identities.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function newIdentityRegistry(
|
||||
tokenFactory: (() => string) | undefined,
|
||||
): RuntimeIdentityRegistry {
|
||||
return createRuntimeIdentityRegistry({
|
||||
...(tokenFactory ? { tokenFactory } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function scopeFingerprint(tokenFactory: (() => string) | undefined): string {
|
||||
const candidate = tokenFactory?.() ?? crypto.randomUUID();
|
||||
if (!/^[A-Za-z0-9._:-]{16,128}$/.test(candidate)) {
|
||||
throw new TypeError("Invalid cache scope fingerprint.");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type {
|
||||
QueryInvalidationCoordinator,
|
||||
QueryInvalidationTopic,
|
||||
QueryMutationLease,
|
||||
} from "../../contracts/query-invalidation.ts";
|
||||
import { isCacheInvalidationTopic } from "../../contracts/cache-invalidation.ts";
|
||||
import type {
|
||||
BrowserCrossContextInvalidation,
|
||||
CrossContextInvalidationDelivery,
|
||||
} from "../cross-context-invalidation/index.ts";
|
||||
|
||||
export type InstalledQueryInvalidationDefinition = Readonly<{
|
||||
namespace: readonly unknown[];
|
||||
invalidationTopic: QueryInvalidationTopic;
|
||||
crossContext: "invalidate-only";
|
||||
version: number;
|
||||
persistence: "disabled";
|
||||
}>;
|
||||
|
||||
export type TanStackCacheCoordinatorDependencies = Readonly<{
|
||||
queryClient: QueryClient;
|
||||
queryRegistry: Readonly<
|
||||
Record<string, InstalledQueryInvalidationDefinition>
|
||||
>;
|
||||
crossContext?: BrowserCrossContextInvalidation;
|
||||
diagnostics?: DiagnosticsPort;
|
||||
}>;
|
||||
|
||||
type RuntimeDefinition = Readonly<{
|
||||
namespace: readonly unknown[];
|
||||
topic: QueryInvalidationTopic;
|
||||
version: number;
|
||||
}>;
|
||||
|
||||
const MAX_NAMESPACE_PARTS = 8;
|
||||
const MAX_NAMESPACE_BYTES = 1_024;
|
||||
|
||||
/**
|
||||
* Joins registry-owned invalidation topics to TanStack Query without putting a
|
||||
* query key or cached value on the cross-context wire.
|
||||
*/
|
||||
export function createTanStackCacheCoordinator(
|
||||
dependencies: TanStackCacheCoordinatorDependencies,
|
||||
): QueryInvalidationCoordinator {
|
||||
const definitions = buildDefinitions(dependencies.queryRegistry);
|
||||
const mutationLeases = new Map<QueryInvalidationTopic, number>();
|
||||
const pendingRemote = new Set<QueryInvalidationTopic>();
|
||||
let disposed = false;
|
||||
let resetting = false;
|
||||
let lifecycleGeneration = 0;
|
||||
let flushPromise: Promise<void> | null = null;
|
||||
let resetPromise: Promise<void> | null = null;
|
||||
|
||||
const unsubscribe = dependencies.crossContext?.subscribe((delivery) => {
|
||||
receiveRemote(delivery);
|
||||
});
|
||||
|
||||
function definition(topic: QueryInvalidationTopic): RuntimeDefinition {
|
||||
const selected = definitions.get(topic);
|
||||
if (!selected) {
|
||||
throw new TypeError("Unregistered query invalidation topic.");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function invalidateLocal(
|
||||
topic: QueryInvalidationTopic,
|
||||
expectedGeneration = lifecycleGeneration,
|
||||
): Promise<void> {
|
||||
if (
|
||||
disposed ||
|
||||
resetting ||
|
||||
expectedGeneration !== lifecycleGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selected = definition(topic);
|
||||
try {
|
||||
await dependencies.queryClient.invalidateQueries({
|
||||
queryKey: selected.namespace,
|
||||
exact: false,
|
||||
refetchType: "active",
|
||||
});
|
||||
} catch {
|
||||
report("invalidate");
|
||||
}
|
||||
}
|
||||
|
||||
function receiveRemote(
|
||||
delivery: CrossContextInvalidationDelivery,
|
||||
): void {
|
||||
if (disposed) return;
|
||||
const selected = definitions.get(delivery.event.topic);
|
||||
if (!selected) {
|
||||
report("unknown-topic");
|
||||
return;
|
||||
}
|
||||
|
||||
if (delivery.ordering === "GAP") {
|
||||
for (const candidate of definitions.values()) {
|
||||
pendingRemote.add(candidate.topic);
|
||||
}
|
||||
report("sequence-gap");
|
||||
} else {
|
||||
pendingRemote.add(selected.topic);
|
||||
}
|
||||
if (!resetting) void flushRemote();
|
||||
}
|
||||
|
||||
function flushRemote(): Promise<void> {
|
||||
if (disposed || resetting) return Promise.resolve();
|
||||
if (flushPromise) return flushPromise;
|
||||
const expectedGeneration = lifecycleGeneration;
|
||||
|
||||
flushPromise = Promise.resolve()
|
||||
.then(async () => {
|
||||
while (
|
||||
!disposed &&
|
||||
!resetting &&
|
||||
expectedGeneration === lifecycleGeneration
|
||||
) {
|
||||
const ready = [...pendingRemote].filter(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
);
|
||||
if (ready.length === 0) return;
|
||||
for (const topic of ready) {
|
||||
pendingRemote.delete(topic);
|
||||
await invalidateLocal(topic, expectedGeneration);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
report("remote-flush");
|
||||
})
|
||||
.finally(() => {
|
||||
flushPromise = null;
|
||||
if (
|
||||
!disposed &&
|
||||
!resetting &&
|
||||
[...pendingRemote].some(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
)
|
||||
) {
|
||||
void flushRemote();
|
||||
}
|
||||
});
|
||||
return flushPromise;
|
||||
}
|
||||
|
||||
function uniqueTopics(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): readonly QueryInvalidationTopic[] {
|
||||
const unique = [...new Set(topics)];
|
||||
for (const topic of unique) definition(topic);
|
||||
return unique;
|
||||
}
|
||||
|
||||
function report(operation: string): void {
|
||||
try {
|
||||
dependencies.diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "cache.operation.failed",
|
||||
context: {
|
||||
operation,
|
||||
error_kind: "QUERY_CACHE_FAILURE",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Cache correctness and cleanup do not depend on diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async invalidate(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): Promise<void> {
|
||||
if (disposed) return;
|
||||
const selectedTopics = uniqueTopics(topics);
|
||||
for (const topic of selectedTopics) {
|
||||
const selected = definition(topic);
|
||||
await invalidateLocal(topic);
|
||||
const published = dependencies.crossContext?.publish({
|
||||
topic,
|
||||
topicVersion: selected.version,
|
||||
});
|
||||
if (published && !published.ok) {
|
||||
report("cross-context-publish");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
beginMutation(
|
||||
topics: readonly QueryInvalidationTopic[],
|
||||
): QueryMutationLease {
|
||||
if (disposed) {
|
||||
throw new TypeError("Query invalidation coordinator is disposed.");
|
||||
}
|
||||
const selectedTopics = uniqueTopics(topics);
|
||||
for (const topic of selectedTopics) {
|
||||
mutationLeases.set(
|
||||
topic,
|
||||
(mutationLeases.get(topic) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
let released = false;
|
||||
return Object.freeze({
|
||||
async release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
for (const topic of selectedTopics) {
|
||||
const remaining = (mutationLeases.get(topic) ?? 1) - 1;
|
||||
if (remaining <= 0) {
|
||||
mutationLeases.delete(topic);
|
||||
} else {
|
||||
mutationLeases.set(topic, remaining);
|
||||
}
|
||||
}
|
||||
await flushRemote();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async resetLocal() {
|
||||
if (disposed) return;
|
||||
if (resetPromise) return resetPromise;
|
||||
resetting = true;
|
||||
lifecycleGeneration += 1;
|
||||
pendingRemote.clear();
|
||||
mutationLeases.clear();
|
||||
const activeFlush = flushPromise;
|
||||
resetPromise = (async () => {
|
||||
try {
|
||||
await activeFlush;
|
||||
} catch {
|
||||
report("reset-flush");
|
||||
}
|
||||
try {
|
||||
await dependencies.queryClient.cancelQueries();
|
||||
} catch {
|
||||
report("reset-cancel");
|
||||
}
|
||||
dependencies.queryClient.clear();
|
||||
})().finally(() => {
|
||||
resetting = false;
|
||||
resetPromise = null;
|
||||
if (
|
||||
!disposed &&
|
||||
[...pendingRemote].some(
|
||||
(topic) => (mutationLeases.get(topic) ?? 0) === 0,
|
||||
)
|
||||
) {
|
||||
void flushRemote();
|
||||
}
|
||||
});
|
||||
return resetPromise;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
dependencies.crossContext?.close();
|
||||
pendingRemote.clear();
|
||||
mutationLeases.clear();
|
||||
flushPromise = null;
|
||||
resetPromise = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildDefinitions(
|
||||
registry: Readonly<
|
||||
Record<string, InstalledQueryInvalidationDefinition>
|
||||
>,
|
||||
): ReadonlyMap<string, RuntimeDefinition> {
|
||||
const definitions = new Map<string, RuntimeDefinition>();
|
||||
for (const candidate of Object.values(registry)) {
|
||||
if (
|
||||
!candidate ||
|
||||
!isCacheInvalidationTopic(candidate.invalidationTopic) ||
|
||||
candidate.crossContext !== "invalidate-only" ||
|
||||
candidate.persistence !== "disabled" ||
|
||||
!Number.isSafeInteger(candidate.version) ||
|
||||
candidate.version < 1 ||
|
||||
!isSafeNamespace(candidate.namespace) ||
|
||||
definitions.has(candidate.invalidationTopic)
|
||||
) {
|
||||
throw new TypeError("Query invalidation registry is invalid.");
|
||||
}
|
||||
definitions.set(
|
||||
candidate.invalidationTopic,
|
||||
Object.freeze({
|
||||
namespace: Object.freeze(structuredClone(candidate.namespace)),
|
||||
topic: candidate.invalidationTopic,
|
||||
version: candidate.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (definitions.size === 0) {
|
||||
throw new TypeError(
|
||||
"Query invalidation registry requires at least one topic.",
|
||||
);
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
function isSafeNamespace(value: unknown): value is readonly unknown[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 1 ||
|
||||
value.length > MAX_NAMESPACE_PARTS ||
|
||||
typeof value[0] !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return (
|
||||
typeof serialized === "string" &&
|
||||
new TextEncoder().encode(serialized).byteLength <=
|
||||
MAX_NAMESPACE_BYTES
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+22
-21
@@ -1,7 +1,13 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { createFailure } from "../../contracts/errors.js";
|
||||
import { safeErrorKind } from "../../contracts/diagnostics.js";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import { safeErrorKind } from "../../contracts/diagnostics.ts";
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type { QueryCachePort } from "../../application/ports/query-cache-port.ts";
|
||||
|
||||
export type QueryCacheDependencies = Readonly<{
|
||||
diagnostics?: DiagnosticsPort;
|
||||
}>;
|
||||
|
||||
export const QUERY_CACHE_DEFAULTS = Object.freeze({
|
||||
staleTime: 30_000,
|
||||
@@ -12,12 +18,10 @@ export const QUERY_CACHE_DEFAULTS = Object.freeze({
|
||||
persistence: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
|
||||
*/
|
||||
export function createQueryClient(dependencies = {}) {
|
||||
/** @param {string} operation @param {unknown} error */
|
||||
function report(operation, error) {
|
||||
export function createQueryClient(
|
||||
dependencies: QueryCacheDependencies = {},
|
||||
): QueryClient {
|
||||
function report(operation: string, error: unknown): void {
|
||||
try {
|
||||
dependencies.diagnostics?.record({
|
||||
level: "warn",
|
||||
@@ -52,12 +56,10 @@ export function createQueryClient(dependencies = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryClient} queryClient
|
||||
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
|
||||
* @returns {import("../../application/ports/query-cache-port.js").QueryCachePort}
|
||||
*/
|
||||
export function createQueryCacheAdapter(queryClient, dependencies = {}) {
|
||||
export function createQueryCacheAdapter(
|
||||
queryClient: QueryClient,
|
||||
dependencies: QueryCacheDependencies = {},
|
||||
): QueryCachePort {
|
||||
return Object.freeze({
|
||||
read(key) {
|
||||
try {
|
||||
@@ -85,12 +87,11 @@ export function createQueryCacheAdapter(queryClient, dependencies = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} phase
|
||||
* @param {readonly unknown[]} key
|
||||
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
|
||||
*/
|
||||
function cacheFailure(phase, key, diagnostics) {
|
||||
function cacheFailure(
|
||||
phase: string,
|
||||
key: readonly unknown[],
|
||||
diagnostics: DiagnosticsPort | undefined,
|
||||
): Readonly<{ ok: false; error: ReturnType<typeof createFailure> }> {
|
||||
const namespace = typeof key[0] === "string" ? key[0] : "unknown";
|
||||
try {
|
||||
diagnostics?.record({
|
||||
@@ -105,7 +106,7 @@ function cacheFailure(phase, key, diagnostics) {
|
||||
// Cache behavior remains independent from diagnostics.
|
||||
}
|
||||
return {
|
||||
ok: /** @type {false} */ (false),
|
||||
ok: false,
|
||||
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
|
||||
code: `QUERY_CACHE_${phase.toUpperCase()}_FAILED`,
|
||||
causeClass: `namespace:${namespace}`,
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { createFailure } from "../../contracts/errors.js";
|
||||
import { getStorageDefinition } from "../../contracts/storage-keys.js";
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* localStorage?: Storage,
|
||||
* sessionStorage?: Storage,
|
||||
* now?: () => number,
|
||||
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort
|
||||
* }} [dependencies]
|
||||
* @returns {import("../../application/ports/storage-port.js").StoragePort}
|
||||
*/
|
||||
export function createBrowserStorageAdapter(dependencies = {}) {
|
||||
const memory = new Map();
|
||||
const now = dependencies.now ?? Date.now;
|
||||
|
||||
/** @param {string} name */
|
||||
function backendFor(name) {
|
||||
if (name === "localStorage") return dependencies.localStorage;
|
||||
if (name === "sessionStorage") return dependencies.sessionStorage;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
read(logicalName) {
|
||||
let definition;
|
||||
try {
|
||||
definition = getStorageDefinition(logicalName);
|
||||
} catch {
|
||||
return unavailable("read", logicalName, dependencies.diagnostics);
|
||||
}
|
||||
|
||||
const backend = backendFor(definition.backend);
|
||||
try {
|
||||
const raw = backend?.getItem(definition.physicalKey);
|
||||
if (raw === null || raw === undefined) {
|
||||
return { ok: true, value: memory.get(definition.physicalKey) };
|
||||
}
|
||||
const envelope = JSON.parse(raw);
|
||||
if (
|
||||
!envelope ||
|
||||
typeof envelope !== "object" ||
|
||||
envelope.schemaVersion !== definition.schemaVersion
|
||||
) {
|
||||
backend?.removeItem(definition.physicalKey);
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
if (typeof envelope.expiresAt === "number" && envelope.expiresAt <= now()) {
|
||||
backend?.removeItem(definition.physicalKey);
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
return { ok: true, value: structuredClone(envelope.value) };
|
||||
} catch {
|
||||
return unavailable("read", logicalName, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
|
||||
write(logicalName, value) {
|
||||
let definition;
|
||||
try {
|
||||
definition = getStorageDefinition(logicalName);
|
||||
} catch {
|
||||
return unavailable("write", logicalName, dependencies.diagnostics);
|
||||
}
|
||||
|
||||
const expiresAt =
|
||||
typeof definition.ttl === "number" ? now() + definition.ttl : null;
|
||||
const envelope = {
|
||||
schemaVersion: definition.schemaVersion,
|
||||
expiresAt,
|
||||
value: structuredClone(value),
|
||||
};
|
||||
const backend = backendFor(definition.backend);
|
||||
|
||||
try {
|
||||
if (!backend) throw new DOMException("Storage unavailable", "SecurityError");
|
||||
backend.setItem(definition.physicalKey, JSON.stringify(envelope));
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const quota =
|
||||
error instanceof DOMException &&
|
||||
["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(error.name);
|
||||
|
||||
if (definition.quotaFallback === "memory") {
|
||||
memory.set(definition.physicalKey, structuredClone(value));
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
quota,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(quota, "write", logicalName),
|
||||
fallback: "memory",
|
||||
};
|
||||
}
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
quota,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(quota, "write", logicalName),
|
||||
fallback: definition.quotaFallback,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
remove(logicalName) {
|
||||
let definition;
|
||||
try {
|
||||
definition = getStorageDefinition(logicalName);
|
||||
} catch {
|
||||
return unavailable("remove", logicalName, dependencies.diagnostics);
|
||||
}
|
||||
try {
|
||||
backendFor(definition.backend)?.removeItem(definition.physicalKey);
|
||||
memory.delete(definition.physicalKey);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return unavailable("remove", logicalName, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {boolean} quota @param {string} phase @param {string} logicalName */
|
||||
function storageFailure(quota, phase, logicalName) {
|
||||
return createFailure(
|
||||
quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE",
|
||||
"STORAGE",
|
||||
0,
|
||||
{
|
||||
code: `${logicalName}_${phase.toUpperCase()}_${
|
||||
quota ? "QUOTA_EXCEEDED" : "UNAVAILABLE"
|
||||
}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} phase
|
||||
* @param {string} logicalName
|
||||
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
|
||||
*/
|
||||
function unavailable(phase, logicalName, diagnostics) {
|
||||
recordStorageFailure(diagnostics, phase, logicalName, false);
|
||||
return {
|
||||
ok: /** @type {false} */ (false),
|
||||
error: storageFailure(false, phase, logicalName),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
|
||||
* @param {string} phase
|
||||
* @param {string} logicalName
|
||||
* @param {boolean} quota
|
||||
*/
|
||||
function recordStorageFailure(diagnostics, phase, logicalName, quota) {
|
||||
try {
|
||||
diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "storage.operation.failed",
|
||||
context: {
|
||||
operation: `${phase}:${logicalName}`,
|
||||
error_kind: quota
|
||||
? "STORAGE_QUOTA_EXCEEDED"
|
||||
: "STORAGE_UNAVAILABLE",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Storage behavior remains independent from diagnostics.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import {
|
||||
getStorageDefinition,
|
||||
isStorageValueAllowed,
|
||||
type StorageDefinition,
|
||||
} from "../../contracts/storage-keys.ts";
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type {
|
||||
StorageMutationResult,
|
||||
StoragePort,
|
||||
} from "../../application/ports/storage-port.ts";
|
||||
import {
|
||||
assertValidBrowserStorageByteLimit,
|
||||
decodeBrowserStorageEnvelope,
|
||||
DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES,
|
||||
encodeBrowserStorageEnvelope,
|
||||
type BrowserStorageCodecFailure,
|
||||
} from "./browser-storage-codec.ts";
|
||||
|
||||
export type BrowserStorageDependencies = Readonly<{
|
||||
localStorage?: Storage;
|
||||
sessionStorage?: Storage;
|
||||
now?: () => number;
|
||||
diagnostics?: DiagnosticsPort;
|
||||
maxSerializedBytes?: number;
|
||||
resolveDefinition?: (logicalName: string) => StorageDefinition;
|
||||
}>;
|
||||
|
||||
type StorageFailureCause =
|
||||
| "QUOTA_EXCEEDED"
|
||||
| "SIZE_LIMIT_EXCEEDED"
|
||||
| "UNAVAILABLE"
|
||||
| "VALUE_REJECTED";
|
||||
|
||||
export function createBrowserStorageAdapter(
|
||||
dependencies: BrowserStorageDependencies = {},
|
||||
): StoragePort {
|
||||
const memoryOverlay = new Map<string, string>();
|
||||
const suppressedPersistentValues = new Set<string>();
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const maxSerializedBytes =
|
||||
dependencies.maxSerializedBytes ??
|
||||
DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES;
|
||||
const resolveDefinition =
|
||||
dependencies.resolveDefinition ?? getStorageDefinition;
|
||||
assertValidBrowserStorageByteLimit(maxSerializedBytes);
|
||||
|
||||
function backendFor(name: string): Storage | undefined {
|
||||
if (name === "localStorage") return dependencies.localStorage;
|
||||
if (name === "sessionStorage") return dependencies.sessionStorage;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function definitionFor(
|
||||
logicalName: string,
|
||||
phase: string,
|
||||
):
|
||||
| Readonly<{ ok: true; value: StorageDefinition }>
|
||||
| Extract<StorageMutationResult, { ok: false }> {
|
||||
try {
|
||||
return { ok: true, value: resolveDefinition(logicalName) };
|
||||
} catch {
|
||||
return unavailable(phase, logicalName, dependencies.diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
function currentTime(
|
||||
phase: string,
|
||||
logicalName: string,
|
||||
):
|
||||
| Readonly<{ ok: true; value: number }>
|
||||
| Extract<StorageMutationResult, { ok: false }> {
|
||||
try {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError("Invalid storage clock.");
|
||||
}
|
||||
return { ok: true, value };
|
||||
} catch {
|
||||
return unavailable(phase, logicalName, dependencies.diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
function discardRecord(
|
||||
definition: StorageDefinition,
|
||||
backend: Storage | undefined,
|
||||
): void {
|
||||
memoryOverlay.delete(definition.physicalKey);
|
||||
suppressedPersistentValues.add(definition.physicalKey);
|
||||
if (!backend) {
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
backend.removeItem(definition.physicalKey);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
} catch {
|
||||
// Keep the in-memory tombstone so the rejected value is not parsed again.
|
||||
}
|
||||
}
|
||||
|
||||
function readEnvelope(
|
||||
raw: string,
|
||||
definition: StorageDefinition,
|
||||
backend: Storage | undefined,
|
||||
logicalName: string,
|
||||
) {
|
||||
const decoded = decodeBrowserStorageEnvelope(raw, maxSerializedBytes);
|
||||
if (!decoded.ok) {
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"discard",
|
||||
logicalName,
|
||||
codecFailureCause(decoded.reason),
|
||||
);
|
||||
discardRecord(definition, backend);
|
||||
return { ok: true as const, value: undefined };
|
||||
}
|
||||
const envelope = decoded.value;
|
||||
const expectsExpiry = typeof definition.ttl === "number";
|
||||
if (
|
||||
envelope.schemaVersion !== definition.schemaVersion ||
|
||||
expectsExpiry !== (envelope.expiresAt !== null) ||
|
||||
!isStorageValueAllowed(definition, envelope.value)
|
||||
) {
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"discard",
|
||||
logicalName,
|
||||
"VALUE_REJECTED",
|
||||
);
|
||||
discardRecord(definition, backend);
|
||||
return { ok: true as const, value: undefined };
|
||||
}
|
||||
if (envelope.expiresAt !== null) {
|
||||
const timestamp = currentTime("read", logicalName);
|
||||
if (!timestamp.ok) return timestamp;
|
||||
if (envelope.expiresAt <= timestamp.value) {
|
||||
discardRecord(definition, backend);
|
||||
return { ok: true as const, value: undefined };
|
||||
}
|
||||
}
|
||||
return { ok: true as const, value: envelope.value };
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
read(logicalName) {
|
||||
const selected = definitionFor(logicalName, "read");
|
||||
if (!selected.ok) return selected;
|
||||
const definition = selected.value;
|
||||
const backend = backendFor(definition.backend);
|
||||
const overlay = memoryOverlay.get(definition.physicalKey);
|
||||
if (overlay !== undefined) {
|
||||
return readEnvelope(
|
||||
overlay,
|
||||
definition,
|
||||
backend,
|
||||
logicalName,
|
||||
);
|
||||
}
|
||||
if (suppressedPersistentValues.has(definition.physicalKey)) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = backend?.getItem(definition.physicalKey);
|
||||
if (raw === null || raw === undefined) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
return readEnvelope(raw, definition, backend, logicalName);
|
||||
} catch {
|
||||
return unavailable("read", logicalName, dependencies.diagnostics);
|
||||
}
|
||||
},
|
||||
|
||||
write(logicalName, value) {
|
||||
const selected = definitionFor(logicalName, "write");
|
||||
if (!selected.ok) return selected;
|
||||
const definition = selected.value;
|
||||
if (!isStorageValueAllowed(definition, value)) {
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
"VALUE_REJECTED",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(
|
||||
"VALUE_REJECTED",
|
||||
"write",
|
||||
logicalName,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let expiresAt: number | null = null;
|
||||
if (typeof definition.ttl === "number") {
|
||||
const timestamp = currentTime("write", logicalName);
|
||||
if (!timestamp.ok) return timestamp;
|
||||
const expiration = timestamp.value + definition.ttl;
|
||||
if (!Number.isSafeInteger(expiration)) {
|
||||
return unavailable(
|
||||
"write",
|
||||
logicalName,
|
||||
dependencies.diagnostics,
|
||||
);
|
||||
}
|
||||
expiresAt = expiration;
|
||||
}
|
||||
|
||||
const encoded = encodeBrowserStorageEnvelope(
|
||||
{
|
||||
schemaVersion: definition.schemaVersion,
|
||||
expiresAt,
|
||||
value,
|
||||
},
|
||||
maxSerializedBytes,
|
||||
);
|
||||
if (!encoded.ok) {
|
||||
const cause = codecFailureCause(encoded.reason);
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
cause,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(cause, "write", logicalName),
|
||||
};
|
||||
}
|
||||
|
||||
if (definition.backend === "memory") {
|
||||
memoryOverlay.set(definition.physicalKey, encoded.value);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const backend = backendFor(definition.backend);
|
||||
try {
|
||||
if (!backend) {
|
||||
throw new DOMException("Storage unavailable", "SecurityError");
|
||||
}
|
||||
backend.setItem(definition.physicalKey, encoded.value);
|
||||
memoryOverlay.delete(definition.physicalKey);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const cause: StorageFailureCause = isQuotaError(error)
|
||||
? "QUOTA_EXCEEDED"
|
||||
: "UNAVAILABLE";
|
||||
if (definition.quotaFallback === "memory") {
|
||||
memoryOverlay.set(definition.physicalKey, encoded.value);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
cause,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(cause, "write", logicalName),
|
||||
fallback: "memory",
|
||||
};
|
||||
}
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"write",
|
||||
logicalName,
|
||||
cause,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure(cause, "write", logicalName),
|
||||
fallback: definition.quotaFallback,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
remove(logicalName) {
|
||||
const selected = definitionFor(logicalName, "remove");
|
||||
if (!selected.ok) return selected;
|
||||
const definition = selected.value;
|
||||
const backend = backendFor(definition.backend);
|
||||
|
||||
memoryOverlay.delete(definition.physicalKey);
|
||||
suppressedPersistentValues.add(definition.physicalKey);
|
||||
try {
|
||||
backend?.removeItem(definition.physicalKey);
|
||||
suppressedPersistentValues.delete(definition.physicalKey);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
recordStorageFailure(
|
||||
dependencies.diagnostics,
|
||||
"remove",
|
||||
logicalName,
|
||||
"UNAVAILABLE",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure("UNAVAILABLE", "remove", logicalName),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function codecFailureCause(
|
||||
reason: BrowserStorageCodecFailure,
|
||||
): StorageFailureCause {
|
||||
return reason === "OVERSIZE"
|
||||
? "SIZE_LIMIT_EXCEEDED"
|
||||
: "VALUE_REJECTED";
|
||||
}
|
||||
|
||||
function isQuotaError(error: unknown): boolean {
|
||||
try {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const name = (error as Readonly<{ name?: unknown }>).name;
|
||||
return (
|
||||
typeof name === "string" &&
|
||||
["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(name)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storageFailure(
|
||||
cause: StorageFailureCause,
|
||||
phase: string,
|
||||
logicalName: string,
|
||||
) {
|
||||
const quota = cause === "QUOTA_EXCEEDED";
|
||||
return createFailure(
|
||||
quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE",
|
||||
"STORAGE",
|
||||
0,
|
||||
{
|
||||
code: `${safeLogicalName(logicalName)}_${phase.toUpperCase()}_${cause}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
phase: string,
|
||||
logicalName: string,
|
||||
diagnostics: DiagnosticsPort | undefined,
|
||||
): Extract<StorageMutationResult, { ok: false }> {
|
||||
recordStorageFailure(
|
||||
diagnostics,
|
||||
phase,
|
||||
logicalName,
|
||||
"UNAVAILABLE",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: storageFailure("UNAVAILABLE", phase, logicalName),
|
||||
};
|
||||
}
|
||||
|
||||
function recordStorageFailure(
|
||||
diagnostics: DiagnosticsPort | undefined,
|
||||
phase: string,
|
||||
logicalName: string,
|
||||
cause: StorageFailureCause,
|
||||
): void {
|
||||
try {
|
||||
diagnostics?.record({
|
||||
level: "warn",
|
||||
eventId: "storage.operation.failed",
|
||||
context: {
|
||||
operation: `${phase}:${safeLogicalName(logicalName)}`,
|
||||
error_kind:
|
||||
cause === "QUOTA_EXCEEDED"
|
||||
? "STORAGE_QUOTA_EXCEEDED"
|
||||
: "STORAGE_UNAVAILABLE",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Storage behavior remains independent from diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
function safeLogicalName(logicalName: string): string {
|
||||
return /^[A-Z][A-Z0-9_]{0,63}$/u.test(logicalName)
|
||||
? logicalName
|
||||
: "UNKNOWN_KEY";
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
export const DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES = 16_384;
|
||||
|
||||
const MAX_VALUE_DEPTH = 32;
|
||||
const MAX_VALUE_NODES = 2_048;
|
||||
const FORBIDDEN_RECORD_KEYS = new Set([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
]);
|
||||
|
||||
export type BrowserStorageEnvelope = Readonly<{
|
||||
schemaVersion: number;
|
||||
expiresAt: number | null;
|
||||
value: unknown;
|
||||
}>;
|
||||
|
||||
export type BrowserStorageCodecFailure =
|
||||
| "INVALID_VALUE"
|
||||
| "MALFORMED_RECORD"
|
||||
| "OVERSIZE";
|
||||
|
||||
export type BrowserStorageCodecResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; reason: BrowserStorageCodecFailure }>;
|
||||
|
||||
/**
|
||||
* Closed JSON codec for small Web Storage values. It rejects values that JSON
|
||||
* would silently coerce or omit, accessors, exotic prototypes and unsafe
|
||||
* record keys before they can cross the persistence boundary.
|
||||
*/
|
||||
export function encodeBrowserStorageEnvelope(
|
||||
envelope: BrowserStorageEnvelope,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<string> {
|
||||
try {
|
||||
if (!validEnvelopeMetadata(envelope)) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
const valueValidation = validateStorageValue(
|
||||
envelope.value,
|
||||
maxSerializedBytes,
|
||||
);
|
||||
if (!valueValidation.ok) return valueValidation;
|
||||
const raw = JSON.stringify(envelope);
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes
|
||||
) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
return { ok: true, value: raw };
|
||||
} catch {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeBrowserStorageEnvelope(
|
||||
raw: string,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<BrowserStorageEnvelope> {
|
||||
try {
|
||||
if (serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isExactEnvelope(parsed)) {
|
||||
return { ok: false, reason: "MALFORMED_RECORD" };
|
||||
}
|
||||
const valueValidation = validateStorageValue(
|
||||
parsed.value,
|
||||
maxSerializedBytes,
|
||||
);
|
||||
if (!valueValidation.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
valueValidation.reason === "OVERSIZE"
|
||||
? "OVERSIZE"
|
||||
: "MALFORMED_RECORD",
|
||||
};
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
} catch {
|
||||
return { ok: false, reason: "MALFORMED_RECORD" };
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidBrowserStorageByteLimit(value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 64) {
|
||||
throw new TypeError(
|
||||
"Browser storage serialized byte limit must be a safe integer of at least 64.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validEnvelopeMetadata(envelope: BrowserStorageEnvelope): boolean {
|
||||
return (
|
||||
Boolean(envelope) &&
|
||||
typeof envelope === "object" &&
|
||||
Number.isSafeInteger(envelope.schemaVersion) &&
|
||||
envelope.schemaVersion > 0 &&
|
||||
(envelope.expiresAt === null ||
|
||||
(Number.isSafeInteger(envelope.expiresAt) && envelope.expiresAt >= 0))
|
||||
);
|
||||
}
|
||||
|
||||
function isExactEnvelope(value: unknown): value is BrowserStorageEnvelope {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const keys = Object.keys(value).sort();
|
||||
if (
|
||||
keys.length !== 3 ||
|
||||
keys[0] !== "expiresAt" ||
|
||||
keys[1] !== "schemaVersion" ||
|
||||
keys[2] !== "value"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return validEnvelopeMetadata(value as BrowserStorageEnvelope);
|
||||
}
|
||||
|
||||
function serializedByteLength(raw: string, limit: number): number {
|
||||
if (raw.length > limit) return limit + 1;
|
||||
return new TextEncoder().encode(raw).byteLength;
|
||||
}
|
||||
|
||||
function validateStorageValue(
|
||||
root: unknown,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<void> {
|
||||
let visited = 0;
|
||||
const ancestors = new Set<object>();
|
||||
|
||||
function visit(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
): BrowserStorageCodecResult<void> {
|
||||
visited += 1;
|
||||
if (visited > MAX_VALUE_NODES || depth > MAX_VALUE_DEPTH) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value))
|
||||
) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value.length > maxSerializedBytes) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (
|
||||
!Array.isArray(value) &&
|
||||
prototype !== Object.prototype &&
|
||||
prototype !== null
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
if (Reflect.ownKeys(value).some((key) => typeof key === "symbol")) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const childValues: unknown[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
if (
|
||||
!Number.isSafeInteger(value.length) ||
|
||||
value.length > MAX_VALUE_NODES
|
||||
) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
const descriptorKeys = Object.keys(descriptors).filter(
|
||||
(key) => key !== "length",
|
||||
);
|
||||
if (descriptorKeys.length !== value.length) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = descriptors[String(index)];
|
||||
if (
|
||||
!descriptor ||
|
||||
!descriptor.enumerable ||
|
||||
!("value" in descriptor)
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
childValues.push(descriptor.value);
|
||||
}
|
||||
} else {
|
||||
for (const [key, descriptor] of Object.entries(descriptors)) {
|
||||
if (
|
||||
key.length > maxSerializedBytes ||
|
||||
FORBIDDEN_RECORD_KEYS.has(key) ||
|
||||
!descriptor.enumerable ||
|
||||
!("value" in descriptor)
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
childValues.push(descriptor.value);
|
||||
}
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
for (const child of childValues) {
|
||||
const result = visit(child, depth + 1);
|
||||
if (!result.ok) return result;
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
return visit(root, 0);
|
||||
} catch {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
assertValidStoragePolicy,
|
||||
isValidByteLength,
|
||||
type BrowserDataFailureCode,
|
||||
type BrowserDataOperation,
|
||||
type BrowserStoragePolicy,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import type { OpfsStorageScope } from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
|
||||
export type OpfsRuntimePolicy = Readonly<{
|
||||
rootDirectoryName: string;
|
||||
mutationLockName: string;
|
||||
chunkSizeBytes: number;
|
||||
maxObjectBytes: number;
|
||||
maxChunkCount: number;
|
||||
rpcTimeoutMs: number;
|
||||
reconciliationBudgetMs: number;
|
||||
reconciliationBatchSize: number;
|
||||
orphanGracePeriodMs: number;
|
||||
orphanGcBatchSize: number;
|
||||
maxCancellationTombstones: number;
|
||||
allowAsyncWritableChunkFallback: boolean;
|
||||
isObjectIdAllowed: (objectId: string) => boolean;
|
||||
isMediaTypeAllowed: (mediaType: string) => boolean;
|
||||
}>;
|
||||
|
||||
export type OpfsSafeObservation = Readonly<{
|
||||
operation: BrowserDataOperation;
|
||||
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
|
||||
failureCode?: BrowserDataFailureCode;
|
||||
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB";
|
||||
transactionBucket?: "0" | "1_10" | "11_100" | "GT_100";
|
||||
}>;
|
||||
|
||||
export type OpfsSafeObserver = (observation: OpfsSafeObservation) => void;
|
||||
|
||||
const SAFE_SEGMENT = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
||||
const OPAQUE_OBJECT_ID = /^[A-Za-z0-9_-]{8,128}$/u;
|
||||
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:\s*;.*)?$/iu;
|
||||
|
||||
export const DEFAULT_OPFS_RUNTIME_POLICY: OpfsRuntimePolicy = Object.freeze({
|
||||
rootDirectoryName: "ca-frontend-opfs-v1",
|
||||
mutationLockName: "ca-frontend-opfs-v1:mutation",
|
||||
chunkSizeBytes: 4 * 1024 * 1024,
|
||||
maxObjectBytes: 2 * 1024 * 1024 * 1024,
|
||||
maxChunkCount: 512,
|
||||
rpcTimeoutMs: 60_000,
|
||||
reconciliationBudgetMs: 5_000,
|
||||
reconciliationBatchSize: 100,
|
||||
orphanGracePeriodMs: 24 * 60 * 60 * 1_000,
|
||||
orphanGcBatchSize: 100,
|
||||
maxCancellationTombstones: 1_024,
|
||||
allowAsyncWritableChunkFallback: true,
|
||||
isObjectIdAllowed: (objectId) => OPAQUE_OBJECT_ID.test(objectId),
|
||||
isMediaTypeAllowed: (mediaType) => MEDIA_TYPE.test(mediaType),
|
||||
});
|
||||
|
||||
export function resolveOpfsRuntimePolicy(
|
||||
policy: Partial<OpfsRuntimePolicy> = {},
|
||||
): OpfsRuntimePolicy {
|
||||
const resolved: OpfsRuntimePolicy = Object.freeze({
|
||||
...DEFAULT_OPFS_RUNTIME_POLICY,
|
||||
...policy,
|
||||
});
|
||||
assertOpfsRuntimePolicy(resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function assertOpfsRuntimePolicy(policy: OpfsRuntimePolicy): void {
|
||||
if (
|
||||
!SAFE_SEGMENT.test(policy.rootDirectoryName) ||
|
||||
policy.mutationLockName.length === 0 ||
|
||||
!Number.isSafeInteger(policy.chunkSizeBytes) ||
|
||||
policy.chunkSizeBytes < 64 * 1024 ||
|
||||
policy.chunkSizeBytes > 64 * 1024 * 1024 ||
|
||||
!isValidByteLength(policy.maxObjectBytes) ||
|
||||
policy.maxObjectBytes < policy.chunkSizeBytes ||
|
||||
!Number.isSafeInteger(policy.maxChunkCount) ||
|
||||
policy.maxChunkCount < 1 ||
|
||||
policy.maxObjectBytes > policy.chunkSizeBytes * policy.maxChunkCount ||
|
||||
!Number.isSafeInteger(policy.rpcTimeoutMs) ||
|
||||
policy.rpcTimeoutMs < 1_000 ||
|
||||
!Number.isSafeInteger(policy.reconciliationBudgetMs) ||
|
||||
policy.reconciliationBudgetMs < 1 ||
|
||||
policy.reconciliationBudgetMs > 60_000 ||
|
||||
!Number.isSafeInteger(policy.reconciliationBatchSize) ||
|
||||
policy.reconciliationBatchSize < 1 ||
|
||||
policy.reconciliationBatchSize > 1_000 ||
|
||||
!Number.isSafeInteger(policy.orphanGracePeriodMs) ||
|
||||
policy.orphanGracePeriodMs < 60_000 ||
|
||||
!Number.isSafeInteger(policy.orphanGcBatchSize) ||
|
||||
policy.orphanGcBatchSize < 1 ||
|
||||
policy.orphanGcBatchSize > 1_000 ||
|
||||
!Number.isSafeInteger(policy.maxCancellationTombstones) ||
|
||||
policy.maxCancellationTombstones < 16 ||
|
||||
policy.maxCancellationTombstones > 10_000 ||
|
||||
typeof policy.isObjectIdAllowed !== "function" ||
|
||||
typeof policy.isMediaTypeAllowed !== "function"
|
||||
) {
|
||||
throw new TypeError("OPFS runtime policy is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateObjectWriteInput(
|
||||
input: Readonly<{
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
expectedGeneration: number | null;
|
||||
mediaType: string;
|
||||
byteLength: number | null;
|
||||
storagePolicy: Parameters<typeof assertValidStoragePolicy>[0];
|
||||
}>,
|
||||
policy: OpfsRuntimePolicy,
|
||||
): boolean {
|
||||
try {
|
||||
assertValidStoragePolicy(input.storagePolicy);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isValidOpfsStorageScope(input.scope) &&
|
||||
input.scope.namespace === input.storagePolicy.namespace &&
|
||||
policy.isObjectIdAllowed(input.objectId) &&
|
||||
policy.isMediaTypeAllowed(input.mediaType) &&
|
||||
(input.expectedGeneration === null ||
|
||||
(Number.isSafeInteger(input.expectedGeneration) &&
|
||||
input.expectedGeneration > 0)) &&
|
||||
input.byteLength !== null &&
|
||||
isValidByteLength(input.byteLength) &&
|
||||
input.byteLength <= policy.maxObjectBytes &&
|
||||
Math.ceil(input.byteLength / policy.chunkSizeBytes) <=
|
||||
policy.maxChunkCount
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidOpfsStorageScope(
|
||||
scope: OpfsStorageScope,
|
||||
): boolean {
|
||||
return (
|
||||
scope.namespace.length > 0 &&
|
||||
scope.namespace.length <= 64 &&
|
||||
OPAQUE_OBJECT_ID.test(scope.authorityToken) &&
|
||||
OPAQUE_OBJECT_ID.test(scope.namespaceToken) &&
|
||||
OPAQUE_OBJECT_ID.test(scope.partitionToken)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the registry binding at composition time. Callers may own mutable
|
||||
* config objects, so no OPFS operation is allowed to retain those references.
|
||||
*/
|
||||
export function snapshotOpfsStorageScope(
|
||||
input: OpfsStorageScope,
|
||||
): OpfsStorageScope {
|
||||
try {
|
||||
const snapshot: OpfsStorageScope = Object.freeze({
|
||||
namespace: input.namespace,
|
||||
authorityToken: input.authorityToken,
|
||||
namespaceToken: input.namespaceToken,
|
||||
partitionToken: input.partitionToken,
|
||||
});
|
||||
if (!isValidOpfsStorageScope(snapshot)) throw new TypeError();
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new TypeError("OPFS storage scope is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep enough for the closed BrowserStoragePolicy contract: retention is the
|
||||
* only nested value. Fields are copied explicitly so later caller mutation or
|
||||
* extension properties cannot alter the bound policy fingerprint.
|
||||
*/
|
||||
export function snapshotOpfsStoragePolicy(
|
||||
input: BrowserStoragePolicy,
|
||||
): BrowserStoragePolicy {
|
||||
try {
|
||||
const retention: BrowserStoragePolicy["retention"] =
|
||||
input.retention.kind === "TTL"
|
||||
? Object.freeze({
|
||||
kind: "TTL",
|
||||
maxAgeMs: input.retention.maxAgeMs,
|
||||
})
|
||||
: Object.freeze({ kind: input.retention.kind });
|
||||
const snapshot: BrowserStoragePolicy = Object.freeze({
|
||||
owner: input.owner,
|
||||
namespace: input.namespace,
|
||||
classification: input.classification,
|
||||
authority: input.authority,
|
||||
accountScope: input.accountScope,
|
||||
retention,
|
||||
softBudgetBytes: input.softBudgetBytes,
|
||||
hardBudgetBytes: input.hardBudgetBytes,
|
||||
evictionPriority: input.evictionPriority,
|
||||
logoutAction: input.logoutAction,
|
||||
accountDeletionAction: input.accountDeletionAction,
|
||||
pressureAction: input.pressureAction,
|
||||
unavailableFallback: input.unavailableFallback,
|
||||
});
|
||||
assertValidStoragePolicy(snapshot);
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new TypeError("OPFS storage policy is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
export function byteBucket(
|
||||
byteLength: number,
|
||||
): NonNullable<OpfsSafeObservation["byteBucket"]> {
|
||||
if (byteLength === 0) return "0";
|
||||
if (byteLength <= 1024 * 1024) return "1B_1MiB";
|
||||
if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB";
|
||||
if (byteLength <= 256 * 1024 * 1024) return "16MiB_256MiB";
|
||||
return "GT_256MiB";
|
||||
}
|
||||
|
||||
export function transactionBucket(
|
||||
count: number,
|
||||
): NonNullable<OpfsSafeObservation["transactionBucket"]> {
|
||||
if (count === 0) return "0";
|
||||
if (count <= 10) return "1_10";
|
||||
if (count <= 100) return "11_100";
|
||||
return "GT_100";
|
||||
}
|
||||
|
||||
export function observeOpfsSafely(
|
||||
observer: OpfsSafeObserver | undefined,
|
||||
observation: OpfsSafeObservation,
|
||||
): void {
|
||||
try {
|
||||
observer?.(Object.freeze({ ...observation }));
|
||||
} catch {
|
||||
// Persistence behavior never depends on observability.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import type {
|
||||
OpfsCapabilities,
|
||||
OpfsPreparedObject,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataOperation,
|
||||
BrowserDataResult,
|
||||
ByteSource,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import type { OpfsRuntimePolicy } from "./opfs-policy.ts";
|
||||
import type {
|
||||
OpfsWorkerGateway,
|
||||
OpfsOrphanCandidateBatch,
|
||||
OpfsOrphanDeleteResult,
|
||||
OpfsWorkerRequest,
|
||||
OpfsWorkerRequestBody,
|
||||
OpfsWorkerResponse,
|
||||
PreparePhysicalObjectRequest,
|
||||
} from "./opfs-worker-protocol.ts";
|
||||
|
||||
export interface OpfsWorkerLike {
|
||||
postMessage(message: OpfsWorkerRequest, transfer?: readonly Transferable[]): void;
|
||||
addEventListener(
|
||||
type: "message",
|
||||
listener: (event: MessageEvent<unknown>) => void,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "message",
|
||||
listener: (event: MessageEvent<unknown>) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
export type OpfsWorkerClientDependencies = Readonly<{
|
||||
worker: OpfsWorkerLike;
|
||||
policy: OpfsRuntimePolicy;
|
||||
createRequestId?: () => string;
|
||||
}>;
|
||||
|
||||
export type OwnedOpfsWorkerClient = Readonly<{
|
||||
gateway: OpfsWorkerGateway;
|
||||
terminate(): void;
|
||||
}>;
|
||||
|
||||
type PendingRequest = Readonly<{
|
||||
resolve: (response: OpfsWorkerResponse) => void;
|
||||
reject: (error: OpfsRpcError) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
removeAbortListener: () => void;
|
||||
}>;
|
||||
|
||||
class OpfsRpcError extends Error {
|
||||
readonly code: BrowserDataFailureCode;
|
||||
|
||||
constructor(code: BrowserDataFailureCode) {
|
||||
super("OPFS worker request failed.");
|
||||
this.name = "OpfsRpcError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpfsWorkerGateway(
|
||||
dependencies: OpfsWorkerClientDependencies,
|
||||
): OpfsWorkerGateway {
|
||||
const createRequestId =
|
||||
dependencies.createRequestId ??
|
||||
(() => globalThis.crypto.randomUUID());
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
let disposed = false;
|
||||
|
||||
const onMessage = (event: MessageEvent<unknown>): void => {
|
||||
if (disposed) return;
|
||||
if (!isWorkerResponse(event.data)) return;
|
||||
const request = pending.get(event.data.requestId);
|
||||
if (!request) return;
|
||||
pending.delete(event.data.requestId);
|
||||
clearTimeout(request.timeout);
|
||||
request.removeAbortListener();
|
||||
request.resolve(event.data);
|
||||
};
|
||||
dependencies.worker.addEventListener("message", onMessage);
|
||||
|
||||
async function rpc(
|
||||
request: OpfsWorkerRequestBody,
|
||||
signal?: AbortSignal,
|
||||
transfer: readonly Transferable[] = [],
|
||||
): Promise<OpfsWorkerResponse> {
|
||||
if (disposed) throw new OpfsRpcError("UNAVAILABLE");
|
||||
if (signal?.aborted) throw new OpfsRpcError("ABORTED");
|
||||
const requestId = createRequestId();
|
||||
const message = { ...request, requestId } as OpfsWorkerRequest;
|
||||
|
||||
return await new Promise<OpfsWorkerResponse>((resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
const item = pending.get(requestId);
|
||||
if (!item) return;
|
||||
pending.delete(requestId);
|
||||
clearTimeout(item.timeout);
|
||||
item.removeAbortListener();
|
||||
reject(new OpfsRpcError("ABORTED"));
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
const timeout = setTimeout(() => {
|
||||
const item = pending.get(requestId);
|
||||
if (!item) return;
|
||||
pending.delete(requestId);
|
||||
item.removeAbortListener();
|
||||
reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
}, dependencies.policy.rpcTimeoutMs);
|
||||
pending.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
removeAbortListener: () =>
|
||||
signal?.removeEventListener("abort", abort),
|
||||
});
|
||||
|
||||
try {
|
||||
dependencies.worker.postMessage(message, transfer);
|
||||
} catch {
|
||||
const item = pending.get(requestId);
|
||||
if (item) {
|
||||
pending.delete(requestId);
|
||||
clearTimeout(item.timeout);
|
||||
item.removeAbortListener();
|
||||
}
|
||||
reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function invoke<Value>(
|
||||
operation: BrowserDataOperation,
|
||||
request: OpfsWorkerRequestBody,
|
||||
signal?: AbortSignal,
|
||||
transfer: readonly Transferable[] = [],
|
||||
parse?: (value: unknown) => Value | null,
|
||||
): Promise<BrowserDataResult<Value>> {
|
||||
try {
|
||||
const response = await rpc(request, signal, transfer);
|
||||
if (!response.ok) {
|
||||
return failureResult(response.failure.code, operation);
|
||||
}
|
||||
const parsed = parse?.(response.value);
|
||||
if (parse && parsed === null) {
|
||||
return browserDataFailure("CORRUPT_DATA", operation, {
|
||||
recovery: "REHYDRATE",
|
||||
});
|
||||
}
|
||||
return browserDataSuccess(parsed as Value);
|
||||
} catch (error) {
|
||||
return failureResult(
|
||||
error instanceof OpfsRpcError ? error.code : "UNAVAILABLE",
|
||||
operation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function abortAndCleanup(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await rpc({ kind: "ABORT_PUT", scope, transactionId });
|
||||
} catch {
|
||||
// Journal reconciliation repeats cleanup after a crash or timeout.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async capabilities() {
|
||||
return await invoke(
|
||||
"OBJECT_READ",
|
||||
{ kind: "CAPABILITIES" },
|
||||
undefined,
|
||||
[],
|
||||
parseCapabilities,
|
||||
);
|
||||
},
|
||||
|
||||
async preparePut(request: PreparePhysicalObjectRequest) {
|
||||
const begin = await invoke<void>(
|
||||
"OBJECT_WRITE",
|
||||
{
|
||||
kind: "BEGIN_PUT",
|
||||
transactionId: request.transactionId,
|
||||
scope: request.descriptor.scope,
|
||||
objectId: request.descriptor.objectId,
|
||||
generation: request.descriptor.generation,
|
||||
declaredByteLength: request.descriptor.byteLength,
|
||||
mediaType: request.descriptor.mediaType,
|
||||
createdAtEpochMs: request.descriptor.createdAtEpochMs,
|
||||
storagePolicy: request.descriptor.storagePolicy,
|
||||
chunkSizeBytes: dependencies.policy.chunkSizeBytes,
|
||||
},
|
||||
request.signal,
|
||||
);
|
||||
if (!begin.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return begin;
|
||||
}
|
||||
|
||||
let sequence = 0;
|
||||
let transferredBytes = 0;
|
||||
notifyProgress(request, "TRANSFERRING", 0);
|
||||
try {
|
||||
for await (const chunk of rechunk(
|
||||
request.source.stream(requiredSignal(request.signal)),
|
||||
dependencies.policy.chunkSizeBytes,
|
||||
dependencies.policy.maxObjectBytes,
|
||||
request.signal,
|
||||
)) {
|
||||
const chunkByteLength = chunk.byteLength;
|
||||
const append = await invoke<void>(
|
||||
"OBJECT_WRITE",
|
||||
{
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: request.descriptor.scope,
|
||||
transactionId: request.transactionId,
|
||||
sequence,
|
||||
bytes: chunk,
|
||||
},
|
||||
request.signal,
|
||||
[chunk],
|
||||
);
|
||||
if (!append.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return append;
|
||||
}
|
||||
sequence += 1;
|
||||
transferredBytes += chunkByteLength;
|
||||
notifyProgress(request, "TRANSFERRING", transferredBytes);
|
||||
}
|
||||
if (transferredBytes !== request.descriptor.byteLength) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return browserDataFailure("INTEGRITY_FAILED", "OBJECT_WRITE", {
|
||||
recovery: "RESELECT",
|
||||
});
|
||||
}
|
||||
notifyProgress(request, "VERIFYING", transferredBytes);
|
||||
const finished = await invoke(
|
||||
"OBJECT_WRITE",
|
||||
{
|
||||
kind: "FINISH_PUT",
|
||||
scope: request.descriptor.scope,
|
||||
transactionId: request.transactionId,
|
||||
},
|
||||
request.signal,
|
||||
[],
|
||||
parsePreparedObject,
|
||||
);
|
||||
if (!finished.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
}
|
||||
return finished;
|
||||
} catch (error) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return failureResult(
|
||||
error instanceof OpfsRpcError ? error.code : "NOT_READABLE",
|
||||
"OBJECT_WRITE",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async verifyObject(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke(
|
||||
"OBJECT_READ",
|
||||
{ kind: "VERIFY_OBJECT", preparedObject },
|
||||
signal,
|
||||
[],
|
||||
(value) => (typeof value === "boolean" ? value : null),
|
||||
);
|
||||
},
|
||||
|
||||
async openObject(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const verified = await invoke(
|
||||
"OBJECT_READ",
|
||||
{ kind: "VERIFY_OBJECT", preparedObject },
|
||||
signal,
|
||||
[],
|
||||
(value) => (typeof value === "boolean" ? value : null),
|
||||
);
|
||||
if (!verified.ok) return verified;
|
||||
if (!verified.value) {
|
||||
return browserDataFailure("INTEGRITY_FAILED", "OBJECT_READ", {
|
||||
recovery: "REHYDRATE",
|
||||
});
|
||||
}
|
||||
|
||||
const source: ByteSource = Object.freeze({
|
||||
byteLength: preparedObject.descriptor.byteLength,
|
||||
async *stream(streamSignal: AbortSignal) {
|
||||
for (const chunk of preparedObject.chunks) {
|
||||
if (streamSignal.aborted) {
|
||||
yield browserDataFailure("ABORTED", "OBJECT_READ");
|
||||
return;
|
||||
}
|
||||
const result = await invoke(
|
||||
"OBJECT_READ",
|
||||
{
|
||||
kind: "READ_CHUNK",
|
||||
preparedObject,
|
||||
sequence: chunk.sequence,
|
||||
},
|
||||
streamSignal,
|
||||
[],
|
||||
(value) => (value instanceof ArrayBuffer ? value : null),
|
||||
);
|
||||
if (!result.ok) {
|
||||
yield result;
|
||||
return;
|
||||
}
|
||||
yield browserDataSuccess(new Uint8Array(result.value));
|
||||
}
|
||||
},
|
||||
});
|
||||
return browserDataSuccess(source);
|
||||
},
|
||||
|
||||
async removeObject(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
objectId: string,
|
||||
generation: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke<void>(
|
||||
"OBJECT_DELETE",
|
||||
{ kind: "REMOVE_OBJECT", scope, objectId, generation },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
async cleanupTransaction(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke<void>(
|
||||
"OBJECT_RECONCILE",
|
||||
{ kind: "CLEANUP_TRANSACTION", scope, transactionId },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
async finalizePut(
|
||||
transactionId: string,
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke<void>(
|
||||
"OBJECT_RECONCILE",
|
||||
{ kind: "FINALIZE_PUT", transactionId, preparedObject },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
async listOrphanCandidates(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
olderThanEpochMs: number,
|
||||
maxEntries: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke(
|
||||
"OBJECT_RECONCILE",
|
||||
{
|
||||
kind: "LIST_ORPHAN_CANDIDATES",
|
||||
scope,
|
||||
olderThanEpochMs,
|
||||
maxEntries,
|
||||
},
|
||||
signal,
|
||||
[],
|
||||
parseOrphanCandidateBatch,
|
||||
);
|
||||
},
|
||||
|
||||
async deleteOrphanChunk(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
digestHex: string,
|
||||
olderThanEpochMs: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke(
|
||||
"OBJECT_RECONCILE",
|
||||
{
|
||||
kind: "DELETE_ORPHAN_CHUNK",
|
||||
scope,
|
||||
digestHex,
|
||||
olderThanEpochMs,
|
||||
},
|
||||
signal,
|
||||
[],
|
||||
parseOrphanDeleteResult,
|
||||
);
|
||||
},
|
||||
|
||||
close() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
dependencies.worker.removeEventListener("message", onMessage);
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.removeAbortListener();
|
||||
request.reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
}
|
||||
pending.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createOwnedOpfsWorkerClient(
|
||||
dependencies: Readonly<{
|
||||
workerUrl: string | URL;
|
||||
policy: OpfsRuntimePolicy;
|
||||
workerName?: string;
|
||||
createRequestId?: () => string;
|
||||
}>,
|
||||
): OwnedOpfsWorkerClient {
|
||||
const worker = new Worker(dependencies.workerUrl, {
|
||||
type: "module",
|
||||
name: dependencies.workerName ?? "ca-opfs-byte-store",
|
||||
});
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker,
|
||||
policy: dependencies.policy,
|
||||
createRequestId: dependencies.createRequestId,
|
||||
});
|
||||
return Object.freeze({
|
||||
gateway,
|
||||
terminate: () => {
|
||||
gateway.close();
|
||||
worker.terminate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function* rechunk(
|
||||
source: AsyncIterable<BrowserDataResult<Uint8Array>>,
|
||||
chunkSize: number,
|
||||
maxBytes: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): AsyncGenerator<ArrayBuffer> {
|
||||
let target = new Uint8Array(chunkSize);
|
||||
let targetOffset = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for await (const sourceResult of source) {
|
||||
if (signal?.aborted) throw new OpfsRpcError("ABORTED");
|
||||
if (!sourceResult.ok) {
|
||||
throw new OpfsRpcError(sourceResult.error.code);
|
||||
}
|
||||
const sourceChunk = sourceResult.value;
|
||||
if (!(sourceChunk instanceof Uint8Array)) {
|
||||
throw new OpfsRpcError("CORRUPT_DATA");
|
||||
}
|
||||
let sourceOffset = 0;
|
||||
totalBytes += sourceChunk.byteLength;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > maxBytes) {
|
||||
throw new OpfsRpcError("LIMIT_EXCEEDED");
|
||||
}
|
||||
while (sourceOffset < sourceChunk.byteLength) {
|
||||
const copyLength = Math.min(
|
||||
chunkSize - targetOffset,
|
||||
sourceChunk.byteLength - sourceOffset,
|
||||
);
|
||||
target.set(
|
||||
sourceChunk.subarray(sourceOffset, sourceOffset + copyLength),
|
||||
targetOffset,
|
||||
);
|
||||
sourceOffset += copyLength;
|
||||
targetOffset += copyLength;
|
||||
if (targetOffset === chunkSize) {
|
||||
yield target.buffer as ArrayBuffer;
|
||||
target = new Uint8Array(chunkSize);
|
||||
targetOffset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetOffset > 0) {
|
||||
yield target.slice(0, targetOffset).buffer as ArrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredSignal(signal: AbortSignal | undefined): AbortSignal {
|
||||
return signal ?? new AbortController().signal;
|
||||
}
|
||||
|
||||
function notifyProgress(
|
||||
request: PreparePhysicalObjectRequest,
|
||||
phase: "TRANSFERRING" | "VERIFYING",
|
||||
transferredBytes: number,
|
||||
): void {
|
||||
try {
|
||||
request.onProgress?.({
|
||||
phase,
|
||||
transferredBytes,
|
||||
totalBytes: request.descriptor.byteLength,
|
||||
});
|
||||
} catch {
|
||||
// A UI callback cannot affect the write protocol.
|
||||
}
|
||||
}
|
||||
|
||||
function failureResult(
|
||||
code: BrowserDataFailureCode,
|
||||
operation: BrowserDataOperation,
|
||||
): BrowserDataResult<never> {
|
||||
if (code === "ABORTED") return browserDataFailure(code, operation);
|
||||
if (code === "QUOTA_EXCEEDED") {
|
||||
return browserDataFailure(code, operation, {
|
||||
retryable: true,
|
||||
recovery: "READ_ONLY",
|
||||
});
|
||||
}
|
||||
if (code === "INTEGRITY_FAILED" || code === "CORRUPT_DATA") {
|
||||
return browserDataFailure(code, operation, { recovery: "REHYDRATE" });
|
||||
}
|
||||
if (code === "UNSUPPORTED" || code === "UNAVAILABLE") {
|
||||
return browserDataFailure(code, operation, {
|
||||
retryable: code === "UNAVAILABLE",
|
||||
recovery: "ONLINE_ONLY",
|
||||
});
|
||||
}
|
||||
return browserDataFailure(code, operation, {
|
||||
retryable: code === "BLOCKED" || code === "NOT_READABLE",
|
||||
recovery: code === "NOT_FOUND" ? "REHYDRATE" : "RETRY",
|
||||
});
|
||||
}
|
||||
|
||||
function parseCapabilities(value: unknown): OpfsCapabilities | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("available" in value) ||
|
||||
typeof value.available !== "boolean" ||
|
||||
!("dedicatedWorkerRequired" in value) ||
|
||||
value.dedicatedWorkerRequired !== true ||
|
||||
!("crossContextMutationLockAvailable" in value) ||
|
||||
typeof value.crossContextMutationLockAvailable !== "boolean" ||
|
||||
!("synchronousAccessHandleAvailable" in value) ||
|
||||
typeof value.synchronousAccessHandleAvailable !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as OpfsCapabilities;
|
||||
}
|
||||
|
||||
function parsePreparedObject(value: unknown): OpfsPreparedObject | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("physicalSchemaVersion" in value) ||
|
||||
value.physicalSchemaVersion !== 1 ||
|
||||
!("descriptor" in value) ||
|
||||
!("chunks" in value) ||
|
||||
!Array.isArray(value.chunks)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as OpfsPreparedObject;
|
||||
}
|
||||
|
||||
function parseOrphanCandidateBatch(
|
||||
value: unknown,
|
||||
): OpfsOrphanCandidateBatch | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("safeToSweep" in value) ||
|
||||
typeof value.safeToSweep !== "boolean" ||
|
||||
!("digests" in value) ||
|
||||
!Array.isArray(value.digests) ||
|
||||
!value.digests.every(
|
||||
(digest) =>
|
||||
typeof digest === "string" && /^[a-f0-9]{64}$/u.test(digest),
|
||||
) ||
|
||||
!("moreAvailable" in value) ||
|
||||
typeof value.moreAvailable !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as OpfsOrphanCandidateBatch;
|
||||
}
|
||||
|
||||
function parseOrphanDeleteResult(
|
||||
value: unknown,
|
||||
): OpfsOrphanDeleteResult | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("deleted" in value) ||
|
||||
typeof value.deleted !== "boolean" ||
|
||||
!("skippedUnsafe" in value) ||
|
||||
typeof value.skippedUnsafe !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as OpfsOrphanDeleteResult;
|
||||
}
|
||||
|
||||
function isWorkerResponse(value: unknown): value is OpfsWorkerResponse {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"requestId" in value &&
|
||||
typeof value.requestId === "string" &&
|
||||
"ok" in value &&
|
||||
typeof value.ok === "boolean",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
DurableObjectDescriptor,
|
||||
OpfsCapabilities,
|
||||
OpfsPreparedObject,
|
||||
OpfsStorageScope,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataResult,
|
||||
BrowserStoragePolicy,
|
||||
ByteSource,
|
||||
TransferProgress,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
export type OpfsWorkerRequest =
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "CAPABILITIES";
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "BEGIN_PUT";
|
||||
transactionId: string;
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
generation: number;
|
||||
declaredByteLength: number;
|
||||
mediaType: string;
|
||||
createdAtEpochMs: number;
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
chunkSizeBytes: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "APPEND_CHUNK";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
sequence: number;
|
||||
bytes: ArrayBuffer;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "FINISH_PUT";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "ABORT_PUT";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "VERIFY_OBJECT";
|
||||
preparedObject: OpfsPreparedObject;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "READ_CHUNK";
|
||||
preparedObject: OpfsPreparedObject;
|
||||
sequence: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "REMOVE_OBJECT";
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
generation: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "CLEANUP_TRANSACTION";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "FINALIZE_PUT";
|
||||
transactionId: string;
|
||||
preparedObject: OpfsPreparedObject;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "LIST_ORPHAN_CANDIDATES";
|
||||
scope: OpfsStorageScope;
|
||||
olderThanEpochMs: number;
|
||||
maxEntries: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "DELETE_ORPHAN_CHUNK";
|
||||
scope: OpfsStorageScope;
|
||||
digestHex: string;
|
||||
olderThanEpochMs: number;
|
||||
}>;
|
||||
|
||||
export type OpfsWorkerRequestBody =
|
||||
OpfsWorkerRequest extends infer Request
|
||||
? Request extends OpfsWorkerRequest
|
||||
? Omit<Request, "requestId">
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type OpfsWorkerFailure = Readonly<{
|
||||
code: BrowserDataFailureCode;
|
||||
retryable: boolean;
|
||||
}>;
|
||||
|
||||
export type OpfsOrphanCandidateBatch = Readonly<{
|
||||
safeToSweep: boolean;
|
||||
digests: readonly string[];
|
||||
moreAvailable: boolean;
|
||||
}>;
|
||||
|
||||
export type OpfsOrphanDeleteResult = Readonly<{
|
||||
deleted: boolean;
|
||||
skippedUnsafe: boolean;
|
||||
}>;
|
||||
|
||||
export type OpfsWorkerResponse =
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
ok: true;
|
||||
value?:
|
||||
| OpfsCapabilities
|
||||
| OpfsPreparedObject
|
||||
| ArrayBuffer
|
||||
| boolean
|
||||
| OpfsOrphanCandidateBatch
|
||||
| OpfsOrphanDeleteResult;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
ok: false;
|
||||
failure: OpfsWorkerFailure;
|
||||
}>;
|
||||
|
||||
export type PreparePhysicalObjectRequest = Readonly<{
|
||||
transactionId: string;
|
||||
descriptor: Omit<DurableObjectDescriptor, "integrity">;
|
||||
source: ByteSource;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: TransferProgress) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The coordinator depends on this technology-neutral worker gateway. The
|
||||
* browser implementation below the boundary owns Worker, MessageEvent and
|
||||
* transferable ArrayBuffer instances.
|
||||
*/
|
||||
export interface OpfsWorkerGateway {
|
||||
capabilities(): Promise<BrowserDataResult<OpfsCapabilities>>;
|
||||
preparePut(
|
||||
request: PreparePhysicalObjectRequest,
|
||||
): Promise<BrowserDataResult<OpfsPreparedObject>>;
|
||||
verifyObject(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<boolean>>;
|
||||
openObject(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<ByteSource>>;
|
||||
removeObject(
|
||||
scope: OpfsStorageScope,
|
||||
objectId: string,
|
||||
generation: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
cleanupTransaction(
|
||||
scope: OpfsStorageScope,
|
||||
transactionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
finalizePut(
|
||||
transactionId: string,
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
listOrphanCandidates(
|
||||
scope: OpfsStorageScope,
|
||||
olderThanEpochMs: number,
|
||||
maxEntries: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<OpfsOrphanCandidateBatch>>;
|
||||
deleteOrphanChunk(
|
||||
scope: OpfsStorageScope,
|
||||
digestHex: string,
|
||||
olderThanEpochMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<OpfsOrphanDeleteResult>>;
|
||||
close(): void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+59
-39
@@ -1,7 +1,33 @@
|
||||
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
|
||||
import { queueSizeBucket } from "../../contracts/diagnostics.js";
|
||||
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
|
||||
import { queueSizeBucket } from "../../contracts/diagnostic-buckets.ts";
|
||||
import type {
|
||||
TelemetryEvent,
|
||||
TelemetryEventName,
|
||||
} from "../../contracts/telemetry.ts";
|
||||
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
|
||||
|
||||
export const noOpTelemetry = Object.freeze({
|
||||
export type TelemetryAdapter = TelemetryPort &
|
||||
Readonly<{
|
||||
flush(): Promise<void>;
|
||||
pendingCount(): number;
|
||||
droppedCount(): number;
|
||||
dropReasons(): Readonly<Record<string, number>>;
|
||||
deliveryEvidence(): TelemetryEvent | null;
|
||||
dispose(): void;
|
||||
}>;
|
||||
|
||||
export type TelemetryAdapterOptions = Readonly<{
|
||||
enabled: boolean;
|
||||
endpoint?: string;
|
||||
fetcher?: typeof fetch;
|
||||
maxQueue?: number;
|
||||
schedule?: (callback: () => void) => void;
|
||||
now?: () => number;
|
||||
onDrop?: (event: TelemetryEvent) => void;
|
||||
lifecycle?: Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
||||
}>;
|
||||
|
||||
export const noOpTelemetry: TelemetryAdapter = Object.freeze({
|
||||
emit: () => {},
|
||||
flush: async () => {},
|
||||
pendingCount: () => 0,
|
||||
@@ -11,37 +37,23 @@ export const noOpTelemetry = Object.freeze({
|
||||
dispose: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* enabled: boolean,
|
||||
* endpoint?: string,
|
||||
* fetcher?: typeof fetch,
|
||||
* maxQueue?: number,
|
||||
* schedule?: (callback: () => void) => void,
|
||||
* now?: () => number,
|
||||
* onDrop?: (event: Readonly<Record<string, unknown>>) => void,
|
||||
* lifecycle?: Pick<EventTarget, "addEventListener" | "removeEventListener">
|
||||
* }} options
|
||||
*/
|
||||
export function createTelemetryAdapter(options) {
|
||||
export function createTelemetryAdapter(
|
||||
options: TelemetryAdapterOptions,
|
||||
): TelemetryAdapter {
|
||||
if (!options.enabled || !options.endpoint) {
|
||||
return noOpTelemetry;
|
||||
}
|
||||
|
||||
const endpoint = /** @type {string} */ (options.endpoint);
|
||||
const endpoint = options.endpoint;
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const maxQueue = Math.max(1, options.maxQueue ?? 100);
|
||||
const schedule = options.schedule ?? queueMicrotask;
|
||||
const queue =
|
||||
/** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ (
|
||||
[]
|
||||
);
|
||||
const queue: TelemetryEvent[] = [];
|
||||
let scheduled = false;
|
||||
let flushing = false;
|
||||
let dropped = 0;
|
||||
const dropReasons = new Map();
|
||||
let lastDeliveryEvidence =
|
||||
/** @type {Readonly<Record<string, unknown>> | null} */ (null);
|
||||
const dropReasons = new Map<string, number>();
|
||||
let lastDeliveryEvidence: TelemetryEvent | null = null;
|
||||
const lifecycle =
|
||||
options.lifecycle ??
|
||||
(typeof globalThis.addEventListener === "function" &&
|
||||
@@ -49,8 +61,7 @@ export function createTelemetryAdapter(options) {
|
||||
? globalThis
|
||||
: undefined);
|
||||
|
||||
/** @param {string} reason @param {number} count */
|
||||
function recordDrop(reason, count = 1) {
|
||||
function recordDrop(reason: string, count = 1): void {
|
||||
const safeReason =
|
||||
{
|
||||
"queue-full": "queue-full",
|
||||
@@ -81,8 +92,19 @@ export function createTelemetryAdapter(options) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} eventName @param {Record<string, unknown>} attributes */
|
||||
function emit(eventName, attributes) {
|
||||
function scheduleFlush(): void {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
schedule(() => {
|
||||
scheduled = false;
|
||||
void flush();
|
||||
});
|
||||
}
|
||||
|
||||
function emit(
|
||||
eventName: TelemetryEventName,
|
||||
attributes: Record<string, unknown>,
|
||||
): void {
|
||||
const projected = projectTelemetryEvent(
|
||||
eventName,
|
||||
attributes,
|
||||
@@ -99,16 +121,10 @@ export function createTelemetryAdapter(options) {
|
||||
}
|
||||
queue.push(projected.event);
|
||||
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
schedule(() => {
|
||||
scheduled = false;
|
||||
void flush();
|
||||
});
|
||||
}
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
async function flush(): Promise<void> {
|
||||
if (flushing || queue.length === 0) return;
|
||||
flushing = true;
|
||||
const batch = queue.splice(0, queue.length);
|
||||
@@ -124,6 +140,9 @@ export function createTelemetryAdapter(options) {
|
||||
recordDrop("sink-failure", batch.length);
|
||||
} finally {
|
||||
flushing = false;
|
||||
if (queue.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +151,7 @@ export function createTelemetryAdapter(options) {
|
||||
};
|
||||
lifecycle?.addEventListener("pagehide", flushBeforePageExit);
|
||||
|
||||
function dispose() {
|
||||
function dispose(): void {
|
||||
lifecycle?.removeEventListener("pagehide", flushBeforePageExit);
|
||||
}
|
||||
|
||||
@@ -152,9 +171,10 @@ export function createTelemetryAdapter(options) {
|
||||
* Propagates only a structurally valid W3C traceparent. Invalid/raw headers are
|
||||
* discarded rather than logged or surfaced.
|
||||
*
|
||||
* @param {string | null | undefined} traceparent
|
||||
*/
|
||||
export function safeTraceparent(traceparent) {
|
||||
export function safeTraceparent(
|
||||
traceparent: string | null | undefined,
|
||||
): string | null {
|
||||
return typeof traceparent === "string" &&
|
||||
/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/i.test(traceparent)
|
||||
? traceparent.toLowerCase()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user