chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -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>);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user