chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -6,6 +6,11 @@ import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortTimerSnapshot,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import { parseStaticImageHeaderMetadata } from "./image-header-metadata.ts";
|
||||
|
||||
export type DecodedImageFacade = Readonly<{
|
||||
@@ -47,7 +52,7 @@ export function createBrowserImageProbe(
|
||||
? async (image: Blob) => createImageBitmap(image)
|
||||
: undefined);
|
||||
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const scheduler = snapshotScheduler(
|
||||
const timers = snapshotAbortTimers(
|
||||
dependencies.scheduler ?? defaultScheduler(),
|
||||
);
|
||||
if (
|
||||
@@ -98,10 +103,15 @@ export function createBrowserImageProbe(
|
||||
const scope = createProbeAbortScope(
|
||||
request.signal,
|
||||
timeoutMs,
|
||||
scheduler,
|
||||
timers,
|
||||
);
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
if (scope.signal.aborted) {
|
||||
// Nothing physical has started yet: an already aborted caller or a
|
||||
// deadline that could not be installed ends the probe before fetch.
|
||||
return signalFailure(request.signal);
|
||||
}
|
||||
try {
|
||||
const fetchTask = Promise.resolve(
|
||||
fetcher(url.href, {
|
||||
@@ -114,15 +124,11 @@ export function createBrowserImageProbe(
|
||||
signal: scope.signal,
|
||||
}),
|
||||
);
|
||||
response = await awaitWithAbort(
|
||||
fetchTask,
|
||||
scope.signal,
|
||||
(lateResponse) => {
|
||||
cancelResponseBody(lateResponse);
|
||||
},
|
||||
);
|
||||
response = await scope.await(fetchTask, (lateResponse) => {
|
||||
cancelResponseBody(lateResponse);
|
||||
});
|
||||
} catch {
|
||||
return signalFailure(request.signal, scope);
|
||||
return signalFailure(request.signal);
|
||||
}
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
@@ -150,7 +156,7 @@ export function createBrowserImageProbe(
|
||||
);
|
||||
} catch (error) {
|
||||
if (request.signal.aborted || scope.timedOut()) {
|
||||
return signalFailure(request.signal, scope);
|
||||
return signalFailure(request.signal);
|
||||
}
|
||||
return error instanceof EncodedBodyLimitError
|
||||
? browserDataFailure(
|
||||
@@ -221,11 +227,7 @@ export function createBrowserImageProbe(
|
||||
type: request.expectedMediaType,
|
||||
}),
|
||||
);
|
||||
bitmap = await awaitWithAbort(
|
||||
decodeTask,
|
||||
scope.signal,
|
||||
closeBitmap,
|
||||
);
|
||||
bitmap = await scope.await(decodeTask, closeBitmap);
|
||||
if (
|
||||
!positiveSafeInteger(bitmap.width) ||
|
||||
!positiveSafeInteger(bitmap.height) ||
|
||||
@@ -254,7 +256,7 @@ export function createBrowserImageProbe(
|
||||
);
|
||||
} catch {
|
||||
return request.signal.aborted || scope.timedOut()
|
||||
? signalFailure(request.signal, scope)
|
||||
? signalFailure(request.signal)
|
||||
: browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"IMAGE_RESOLVE",
|
||||
@@ -310,9 +312,13 @@ function validResponseHeaders(
|
||||
);
|
||||
if (!directives) return false;
|
||||
if (request.delivery === "PRIVATE_SIGNED") {
|
||||
return (
|
||||
directives.get("no-store") === true &&
|
||||
!directives.has("public")
|
||||
// TR-RR-09. The recorded BT-IMG-02 contract is a fail-closed matrix, not a
|
||||
// pair of checks: a private response must carry `no-store` and nothing else
|
||||
// that describes cacheability. Only a syntactically valid unknown extension
|
||||
// is ignored, so a contradictory pairing can never read as acceptable.
|
||||
if (directives.get("no-store") !== true) return false;
|
||||
return !PRIVATE_FORBIDDEN_DIRECTIVES.some((name) =>
|
||||
directives.has(name),
|
||||
);
|
||||
}
|
||||
const maxAge = directives.get("max-age");
|
||||
@@ -336,6 +342,92 @@ function validResponseHeaders(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-RR-09. Every cacheability directive a `PRIVATE_SIGNED` response may not
|
||||
* carry alongside `no-store`.
|
||||
*/
|
||||
const PRIVATE_FORBIDDEN_DIRECTIVES: readonly string[] = Object.freeze([
|
||||
"public",
|
||||
"private",
|
||||
"immutable",
|
||||
"max-age",
|
||||
"s-maxage",
|
||||
"no-cache",
|
||||
"must-revalidate",
|
||||
"proxy-revalidate",
|
||||
]);
|
||||
|
||||
/**
|
||||
* BT-IMG-02. Quote- and escape-aware Cache-Control tokenizer.
|
||||
*
|
||||
* A naive comma split plus `replace(/^"|"$/g, "")` accepted `max-age="60` and
|
||||
* `max-age=60"` as the number 60, so a malformed policy could be approved as an
|
||||
* immutable public response. A comma inside a quoted extension value is also
|
||||
* not a directive boundary.
|
||||
*/
|
||||
function splitCacheControlDirectives(value: string): string[] | null {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
let escaped = false;
|
||||
for (const character of value) {
|
||||
if (escaped) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
// quoted-pair may not carry a bare control character.
|
||||
if (code <= 0x1f || code === 0x7f) return null;
|
||||
current += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (inQuotes && character === "\\") {
|
||||
escaped = true;
|
||||
current += character;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += character;
|
||||
continue;
|
||||
}
|
||||
if (character === "," && !inQuotes) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += character;
|
||||
}
|
||||
// An unterminated quoted-string or a dangling escape is malformed.
|
||||
if (inQuotes || escaped) return null;
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function unquoteCacheControlValue(rawValue: string): string | null {
|
||||
if (!rawValue.startsWith('"')) {
|
||||
// A bare value may not contain a quote at all.
|
||||
return rawValue.includes('"') ? null : rawValue;
|
||||
}
|
||||
if (rawValue.length < 2 || !rawValue.endsWith('"')) return null;
|
||||
const inner = rawValue.slice(1, -1);
|
||||
let unquoted = "";
|
||||
let escaped = false;
|
||||
for (const character of inner) {
|
||||
if (escaped) {
|
||||
unquoted += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
// An unescaped quote inside the string means the quoting is unbalanced.
|
||||
if (character === '"') return null;
|
||||
unquoted += character;
|
||||
}
|
||||
return escaped ? null : unquoted;
|
||||
}
|
||||
|
||||
function parseCacheControl(
|
||||
value: string | null,
|
||||
): ReadonlyMap<string, string | true> | null {
|
||||
@@ -348,7 +440,10 @@ function parseCacheControl(
|
||||
"public",
|
||||
]);
|
||||
const directives = new Map<string, string | true>();
|
||||
for (const part of value?.split(",") ?? []) {
|
||||
if (value === null) return directives;
|
||||
const parts = splitCacheControlDirectives(value);
|
||||
if (!parts) return null;
|
||||
for (const part of parts) {
|
||||
const trimmedPart = part.trim();
|
||||
const separator = trimmedPart.indexOf("=");
|
||||
const name = (
|
||||
@@ -367,7 +462,9 @@ function parseCacheControl(
|
||||
if (flagDirectives.has(name)) return null;
|
||||
const rawValue = trimmedPart.slice(separator + 1).trim();
|
||||
if (rawValue === "") return null;
|
||||
directives.set(name, rawValue.replace(/^"|"$/gu, ""));
|
||||
const unquoted = unquoteCacheControlValue(rawValue);
|
||||
if (unquoted === null) return null;
|
||||
directives.set(name, unquoted);
|
||||
}
|
||||
return directives;
|
||||
}
|
||||
@@ -388,11 +485,7 @@ async function readBoundedBody(
|
||||
try {
|
||||
while (true) {
|
||||
if (signal.aborted) throw abortException();
|
||||
const next = await awaitWithAbort(
|
||||
reader.read(),
|
||||
signal,
|
||||
() => undefined,
|
||||
);
|
||||
const next = await readOrAbort(reader.read(), signal);
|
||||
if (next.done) break;
|
||||
if (!(next.value instanceof Uint8Array)) {
|
||||
throw new TypeError("Image response chunk is invalid.");
|
||||
@@ -424,97 +517,99 @@ async function readBoundedBody(
|
||||
|
||||
type ProbeAbortScope = Readonly<{
|
||||
signal: AbortSignal;
|
||||
/** True once a deadline, or a deadline that could not be installed, ended it. */
|
||||
timedOut(): boolean;
|
||||
/**
|
||||
* Awaits `task` under this scope's ownership. A late value is compensated
|
||||
* exactly once; a terminal owner raises the scope's abort exception.
|
||||
*/
|
||||
await<Value>(
|
||||
task: Promise<Value>,
|
||||
onLateValue: (value: Value) => void,
|
||||
): Promise<Value>;
|
||||
release(): void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* X-AUDIT-02 / TR-RR-05. The probe scope is the shared abort primitive with
|
||||
* this subsystem's vocabulary on top. Owning a private copy meant the caller
|
||||
* listener was attached before the timer was installed, so a scheduler that
|
||||
* threw rejected the public `probe()` promise and left the listener behind.
|
||||
*/
|
||||
function createProbeAbortScope(
|
||||
externalSignal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: ImageProbeScheduler,
|
||||
timers: AbortTimerSnapshot,
|
||||
): ProbeAbortScope {
|
||||
const controller = new AbortController();
|
||||
let timeoutReached = false;
|
||||
let released = false;
|
||||
const onExternalAbort = () => {
|
||||
controller.abort(externalSignal.reason);
|
||||
};
|
||||
externalSignal.addEventListener("abort", onExternalAbort, {
|
||||
once: true,
|
||||
const operation = createAbortableOperation({
|
||||
signal: externalSignal,
|
||||
timeoutMs,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
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);
|
||||
signal: operation.signal,
|
||||
// `CLOSED` here means the deadline could never be installed, so the probe
|
||||
// was never bounded: operationally the same unanswered host as a deadline.
|
||||
timedOut: () =>
|
||||
operation.terminal() !== "CALLER_ABORT" && operation.terminal() !== null,
|
||||
async await<Value>(
|
||||
task: Promise<Value>,
|
||||
onLateValue: (value: Value) => void,
|
||||
): Promise<Value> {
|
||||
const raced = await operation.race(task, onLateValue);
|
||||
if (raced.kind === "VALUE") return raced.value;
|
||||
if (raced.kind === "REJECTED") throw raced.reason;
|
||||
throw abortException();
|
||||
},
|
||||
release: () => operation.close(),
|
||||
});
|
||||
}
|
||||
|
||||
function awaitWithAbort<Value>(
|
||||
/**
|
||||
* A single read raced against the probe's ownership. A late chunk is dropped:
|
||||
* the bytes are only ever accumulated by the caller below.
|
||||
*/
|
||||
function readOrAbort<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);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function signalFailure(
|
||||
externalSignal: AbortSignal,
|
||||
scope: ProbeAbortScope,
|
||||
) {
|
||||
function signalFailure(externalSignal: AbortSignal) {
|
||||
// A caller abort is the caller's own verdict; every other owner — a deadline
|
||||
// or a deadline that could never be installed — is an unanswered host.
|
||||
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",
|
||||
});
|
||||
: browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
|
||||
retryable: true,
|
||||
recovery: "RETRY",
|
||||
});
|
||||
}
|
||||
|
||||
function cancelResponseBody(response: Response): void {
|
||||
@@ -564,22 +659,6 @@ function withinDecodeBudget(
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -323,6 +323,11 @@ export function createImageCdnRuntime(
|
||||
);
|
||||
}
|
||||
activeCapabilityVerifications += 1;
|
||||
// TR-RR-07. The slot belongs to the raw verifier, not to this wrapper.
|
||||
// Releasing it when the wrapper's deadline expired let an abandoned
|
||||
// verification keep running while a new one was admitted, so repeated
|
||||
// timeouts produced more physical work than the configured cap allows.
|
||||
const rawVerificationTasks: Promise<unknown>[] = [];
|
||||
try {
|
||||
const canonicalPayload =
|
||||
canonicalImageCapabilityPayload(snapshot);
|
||||
@@ -345,8 +350,10 @@ export function createImageCdnRuntime(
|
||||
if (deadline.signal.aborted) {
|
||||
throw capabilityVerificationAbortException();
|
||||
}
|
||||
const digestTask = sha256Hex(digest, canonicalPayload);
|
||||
rawVerificationTasks.push(digestTask);
|
||||
bindingDigest = await awaitImageRuntimeAbort(
|
||||
sha256Hex(digest, canonicalPayload),
|
||||
digestTask,
|
||||
deadline.signal,
|
||||
);
|
||||
if (
|
||||
@@ -363,14 +370,15 @@ export function createImageCdnRuntime(
|
||||
if (deadline.signal.aborted) {
|
||||
throw capabilityVerificationAbortException();
|
||||
}
|
||||
const verifyTask = verifyCapability({
|
||||
algorithm: snapshot.signature.algorithm,
|
||||
keyId: snapshot.signature.keyId,
|
||||
canonicalPayload: Uint8Array.from(canonicalPayload),
|
||||
signatureBase64Url: snapshot.signature.valueBase64Url,
|
||||
});
|
||||
rawVerificationTasks.push(verifyTask);
|
||||
verified = await awaitImageRuntimeAbort(
|
||||
verifyCapability({
|
||||
algorithm: snapshot.signature.algorithm,
|
||||
keyId: snapshot.signature.keyId,
|
||||
canonicalPayload: Uint8Array.from(canonicalPayload),
|
||||
signatureBase64Url:
|
||||
snapshot.signature.valueBase64Url,
|
||||
}),
|
||||
verifyTask,
|
||||
deadline.signal,
|
||||
);
|
||||
} catch {
|
||||
@@ -427,7 +435,10 @@ export function createImageCdnRuntime(
|
||||
);
|
||||
return browserDataSuccess(reference);
|
||||
} finally {
|
||||
activeCapabilityVerifications -= 1;
|
||||
// Released only once the physical work this slot admitted has settled.
|
||||
void Promise.allSettled(rawVerificationTasks).then(() => {
|
||||
activeCapabilityVerifications -= 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
PRESIGNED_TRANSFER_PROTOCOL,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import type {
|
||||
PresignedDownloadCapability,
|
||||
PresignedTransferBinding,
|
||||
@@ -7,6 +10,11 @@ import type {
|
||||
PresignedUploadPartCapability,
|
||||
PresignedUploadPartCapabilityProvider,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortTimerSnapshot,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
@@ -141,16 +149,20 @@ export function createPresignedCapabilityHttpProvider(
|
||||
);
|
||||
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
|
||||
const now = options.now ?? Date.now;
|
||||
const scheduler =
|
||||
// X-AUDIT-02. The timer callables are captured once, bound to their
|
||||
// receiver, so replacing a scheduler method after composition cannot change
|
||||
// how work already in flight is bounded.
|
||||
const timers = snapshotAbortTimers(
|
||||
options.scheduler ??
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler);
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler),
|
||||
);
|
||||
const credentials = options.controlPlaneCredentials ?? "same-origin";
|
||||
const observer = options.observer;
|
||||
|
||||
@@ -183,9 +195,10 @@ export function createPresignedCapabilityHttpProvider(
|
||||
if (signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
const scope = createAbortScope(signal, timeoutMs, scheduler);
|
||||
const scope = createAbortScope(signal, timeoutMs, timers);
|
||||
try {
|
||||
const response = await fetcher(endpoint, {
|
||||
const raced = await scope.race(
|
||||
fetcher(endpoint, {
|
||||
method: "POST",
|
||||
credentials,
|
||||
redirect: "error",
|
||||
@@ -196,6 +209,10 @@ export function createPresignedCapabilityHttpProvider(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// BT-PRE-02. The server negotiates by request shape: a legacy request
|
||||
// gets a legacy response and a V1 request gets a V1 response. Fields
|
||||
// are never dual-emitted into a strict decoder.
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
method: expected.method,
|
||||
binding: expected.binding,
|
||||
...(expected.mediaType !== undefined
|
||||
@@ -208,8 +225,13 @@ export function createPresignedCapabilityHttpProvider(
|
||||
? { expectedSha256: expected.expectedSha256 }
|
||||
: {}),
|
||||
}),
|
||||
signal: scope.signal,
|
||||
});
|
||||
signal: scope.signal,
|
||||
}),
|
||||
);
|
||||
if (raced === SCOPE_ENDED) {
|
||||
return transferFailure(signal, scope.timedOut());
|
||||
}
|
||||
const response = raced;
|
||||
if (
|
||||
response.redirected ||
|
||||
response.type === "opaqueredirect" ||
|
||||
@@ -440,6 +462,7 @@ function validateCapabilityPayload(
|
||||
try {
|
||||
const payload = strictRecord(value, [
|
||||
"allowedQueryParameters",
|
||||
"protocol",
|
||||
"binding",
|
||||
"byteLength",
|
||||
"capabilityReceipt",
|
||||
@@ -460,6 +483,11 @@ function validateCapabilityPayload(
|
||||
"requiredResponseHeaders",
|
||||
"singleUse",
|
||||
]);
|
||||
if (payload.protocol !== PRESIGNED_TRANSFER_PROTOCOL) {
|
||||
// Missing, V0 and V2 all close the same way: this envelope is not one we
|
||||
// can interpret. No new failure code is introduced.
|
||||
throw new TypeError("Capability transfer protocol is unsupported.");
|
||||
}
|
||||
if (payload.singleUse !== true || payload.method !== context.expected.method) {
|
||||
throw new TypeError("Capability method or replay policy is invalid.");
|
||||
}
|
||||
@@ -618,6 +646,9 @@ function validateCapabilityPayload(
|
||||
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
// TR-RR-03. The registration carries the negotiated protocol version so
|
||||
// the vault validates the same shape the executor later reads.
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt,
|
||||
method: context.expected.method,
|
||||
binding,
|
||||
@@ -854,30 +885,104 @@ function statusFailure(
|
||||
});
|
||||
}
|
||||
|
||||
/** BT-PRE-03. The scope ended before the task settled. */
|
||||
const SCOPE_ENDED = Symbol("presigned-capability-scope-ended");
|
||||
|
||||
/**
|
||||
* TR-RR-01 / TR-RR-05. The abort scope is a thin projection of the shared
|
||||
* `createAbortableOperation` primitive into this subsystem's vocabulary.
|
||||
*
|
||||
* Two things changed with the migration. `abort()` exists, so `close()` on a
|
||||
* download can actually stop a pending fetch instead of only dropping
|
||||
* listeners; and additional ownership signals — a consumer's stream signal —
|
||||
* are composed into the same operation *before* any I/O starts, so an
|
||||
* already-aborted consumer never causes a fetch.
|
||||
*/
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: Scheduler,
|
||||
timers: AbortTimerSnapshot,
|
||||
additionalSignals: readonly AbortSignal[] = [],
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const onAbort = () => controller.abort(external.reason);
|
||||
external.addEventListener("abort", onAbort, { once: true });
|
||||
if (external.aborted) onAbort();
|
||||
const timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
}, timeoutMs);
|
||||
const operation = createAbortableOperation({
|
||||
signal: external,
|
||||
timeoutMs,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
const releases: (() => void)[] = [];
|
||||
for (const extra of additionalSignals) {
|
||||
if (!extra) continue;
|
||||
if (extra.aborted) {
|
||||
operation.close();
|
||||
continue;
|
||||
}
|
||||
const onAbort = () => operation.close();
|
||||
try {
|
||||
extra.addEventListener("abort", onAbort, { once: true });
|
||||
releases.push(() => {
|
||||
try {
|
||||
extra.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// A signal that refuses a listener cannot bound this operation, so the
|
||||
// operation closes rather than running unbounded.
|
||||
operation.close();
|
||||
}
|
||||
}
|
||||
const releaseExtras = () => {
|
||||
for (const release of releases.splice(0)) release();
|
||||
};
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
signal: operation.signal,
|
||||
timedOut: () => operation.terminal() === "DEADLINE",
|
||||
/** Bounds a fetch that ignores its signal. */
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
const outcome = await operation.race(
|
||||
task,
|
||||
(value) => compensateLateResponseValue(value),
|
||||
);
|
||||
if (outcome.kind === "VALUE") return outcome.value;
|
||||
// A collaborator's own rejection stays a rejection: the caller's existing
|
||||
// catch classifies it, and it is never forged into a cancellation.
|
||||
if (outcome.kind === "REJECTED") throw outcome.reason;
|
||||
return SCOPE_ENDED;
|
||||
},
|
||||
/** TR-RR-01. Ends the physical work, not just the bookkeeping. */
|
||||
abort() {
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
release() {
|
||||
scheduler.clearTimeout(timer);
|
||||
external.removeEventListener("abort", onAbort);
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function compensateLateResponseValue(value: unknown): void {
|
||||
const body = (value as { body?: { cancel(): Promise<void> } | null } | null)
|
||||
?.body;
|
||||
void body?.cancel().catch(() => undefined);
|
||||
}
|
||||
|
||||
function transferFailure(
|
||||
signal: AbortSignal,
|
||||
timedOut: boolean,
|
||||
): BrowserDataResult<never> {
|
||||
if (signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
return browserDataFailure(
|
||||
timedOut ? "UNAVAILABLE" : "NOT_READABLE",
|
||||
"PRESIGNED_TRANSFER",
|
||||
{ retryable: true, recovery: "REISSUE_CAPABILITY" },
|
||||
);
|
||||
}
|
||||
|
||||
function isAbortSignal(value: unknown): value is AbortSignal {
|
||||
try {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
@@ -1083,18 +1188,64 @@ function validatePathPrefix(value: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-PRE-05. Object stores and CDNs may percent-decode a path one more time
|
||||
* than this client does, so rejecting only literal `.`, `..` and backslash is
|
||||
* not enough: `%2f`, `%5c` and `%252e%252e` can still become separators or dot
|
||||
* segments downstream.
|
||||
*
|
||||
* Each raw segment is strictly percent-decoded once. The decoded value may not
|
||||
* contain a separator, NUL, a dot segment or a further percent-escape, and
|
||||
* re-encoding it canonically must reproduce the raw segment exactly. That
|
||||
* closes double encoding and mixed-case variants while still allowing any valid
|
||||
* opaque UTF-8 segment.
|
||||
*/
|
||||
function validateExactPath(value: unknown): string {
|
||||
const path = requiredString(value, 2_048);
|
||||
if (
|
||||
!path.startsWith("/") ||
|
||||
path.includes("\\") ||
|
||||
/[\0\r\n]/.test(path) ||
|
||||
path.split("/").some((segment) => segment === "." || segment === "..")
|
||||
/[\0\r\n]/.test(path)
|
||||
) {
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
for (const segment of path.split("/")) {
|
||||
if (segment === "") continue;
|
||||
if (segment === "." || segment === "..") {
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
// Malformed or non-UTF-8 percent-escapes are rejected outright.
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
if (
|
||||
decoded === "." ||
|
||||
decoded === ".." ||
|
||||
decoded.includes("/") ||
|
||||
decoded.includes("\\") ||
|
||||
decoded.includes("\0") ||
|
||||
// A decoded value that still carries a percent-escape would decode again.
|
||||
/%[0-9A-Fa-f]{2}/u.test(decoded)
|
||||
) {
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
if (canonicalPathSegment(decoded) !== segment) {
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Uppercase percent-hex canonical form, matching the provider fixtures. */
|
||||
function canonicalPathSegment(decoded: string): string {
|
||||
return encodeURIComponent(decoded).replace(
|
||||
/%[0-9a-f]{2}/gu,
|
||||
(escape) => escape.toUpperCase(),
|
||||
);
|
||||
}
|
||||
|
||||
class ResponseLimitError extends Error {}
|
||||
class ResponseIntegrityError extends Error {}
|
||||
|
||||
@@ -1,22 +1,73 @@
|
||||
import type {
|
||||
PresignedTransferBinding,
|
||||
PresignedTransferCapability,
|
||||
PresignedTransferCapabilityReceipt,
|
||||
PresignedTransferMethod,
|
||||
PresignedTransferReplayGuard,
|
||||
import {
|
||||
PRESIGNED_TRANSFER_PROTOCOL,
|
||||
type PresignedTransferBinding,
|
||||
type PresignedTransferCapability,
|
||||
type PresignedTransferCapabilityReceipt,
|
||||
type PresignedTransferMethod,
|
||||
type PresignedTransferProtocol,
|
||||
type 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";
|
||||
import {
|
||||
ownDataValue,
|
||||
snapshotExactArray,
|
||||
snapshotExactObject,
|
||||
} from "../../../contracts/exact-snapshot.ts";
|
||||
|
||||
export type PresignedHeaderBinding = Readonly<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* TR-RR-03. A registration is a versioned exact union. Without the version an
|
||||
* issuer from another release could register a shape this vault validates under
|
||||
* different rules than the executor later applies to it.
|
||||
*/
|
||||
/** TR-RR-03. The exact own-data key set a registration may carry. */
|
||||
const REGISTRATION_KEYS: readonly string[] = Object.freeze(
|
||||
[
|
||||
"allowedQueryParameters",
|
||||
"binding",
|
||||
"byteLength",
|
||||
"capabilityReceipt",
|
||||
"digestRequestHeader",
|
||||
"digestResponseHeader",
|
||||
"expectedResponseByteLength",
|
||||
"expectedSha256",
|
||||
"expectedStatus",
|
||||
"expiresAtEpochMs",
|
||||
"href",
|
||||
"maxBytes",
|
||||
"mediaType",
|
||||
"method",
|
||||
"origin",
|
||||
"path",
|
||||
"protocol",
|
||||
"receiptResponseHeader",
|
||||
"requestHeaders",
|
||||
"requiredResponseHeaders",
|
||||
].sort(),
|
||||
);
|
||||
|
||||
/**
|
||||
* TR-RR-03. Ambient credential and cookie headers are forbidden on a presigned
|
||||
* capability: the signature is the authorization.
|
||||
*/
|
||||
const FORBIDDEN_CAPABILITY_HEADERS: ReadonlySet<string> = new Set([
|
||||
"authorization",
|
||||
"cookie",
|
||||
"cookie2",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
]);
|
||||
|
||||
export type PresignedCapabilityRegistration = Readonly<{
|
||||
protocol: PresignedTransferProtocol;
|
||||
capabilityReceipt: PresignedTransferCapabilityReceipt;
|
||||
method: PresignedTransferMethod;
|
||||
binding: PresignedTransferBinding;
|
||||
@@ -109,6 +160,96 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-PRE-04 / TR-RR-03. Runtime invariants every registration must satisfy,
|
||||
* regardless of which issuer produced it.
|
||||
*/
|
||||
function validatePresignedCapabilityRegistration(
|
||||
registration: PresignedCapabilityRegistration,
|
||||
): BrowserDataResult<never> | null {
|
||||
const invalid = () =>
|
||||
browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
});
|
||||
if (!registration || typeof registration !== "object") return invalid();
|
||||
if (registration.protocol !== PRESIGNED_TRANSFER_PROTOCOL) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
typeof registration.capabilityReceipt !== "string" ||
|
||||
registration.capabilityReceipt.length === 0 ||
|
||||
(registration.method !== "GET" && registration.method !== "PUT")
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
let target: URL;
|
||||
let origin: URL;
|
||||
try {
|
||||
target = new URL(registration.href);
|
||||
origin = new URL(registration.origin);
|
||||
} catch {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
// TR-RR-03. A presigned capability travels the network as a bearer of its
|
||||
// own authority, so plaintext is never acceptable.
|
||||
target.protocol !== "https:" ||
|
||||
origin.protocol !== "https:" ||
|
||||
target.origin !== origin.origin ||
|
||||
origin.href.replace(/\/$/u, "") !== registration.origin.replace(/\/$/u, "") ||
|
||||
target.pathname !== registration.path ||
|
||||
target.username.length > 0 ||
|
||||
target.password.length > 0 ||
|
||||
target.hash.length > 0
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
// TR-RR-03. A presigned URL already carries its authorization. An ambient
|
||||
// credential header alongside it would send the user's session to an
|
||||
// origin the capability alone was meant to reach.
|
||||
for (const header of [
|
||||
...registration.requestHeaders,
|
||||
...registration.requiredResponseHeaders,
|
||||
]) {
|
||||
if (
|
||||
FORBIDDEN_CAPABILITY_HEADERS.has(header.name.toLowerCase())
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
}
|
||||
if (
|
||||
registration.allowedQueryParameters.some(
|
||||
(parameter) => typeof parameter !== "string" || parameter.length === 0,
|
||||
)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(registration.expectedStatus) ||
|
||||
registration.expectedStatus < 200 ||
|
||||
registration.expectedStatus > 299 ||
|
||||
(registration.expectedResponseByteLength !== null &&
|
||||
(!Number.isSafeInteger(registration.expectedResponseByteLength) ||
|
||||
registration.expectedResponseByteLength < 0))
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(registration.byteLength) ||
|
||||
registration.byteLength < 0 ||
|
||||
!Number.isSafeInteger(registration.maxBytes) ||
|
||||
registration.maxBytes < registration.byteLength ||
|
||||
!Number.isSafeInteger(registration.expiresAtEpochMs) ||
|
||||
registration.expiresAtEpochMs <= 0 ||
|
||||
!/^[a-f0-9]{64}$/u.test(registration.expectedSha256) ||
|
||||
typeof registration.mediaType !== "string" ||
|
||||
registration.mediaType.length === 0
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
register(
|
||||
registration: PresignedCapabilityRegistration,
|
||||
@@ -116,6 +257,23 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
if (disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
// TR-01. One owned snapshot first, then validate and store only that
|
||||
// snapshot. Validating the issuer's own object and reading it again to
|
||||
// copy it let a stateful answer show an allowed header set to the
|
||||
// forbidden-header check and hand `Authorization` to the copy, so the
|
||||
// vault stored a capability no rule had ever seen.
|
||||
const snapshot = snapshotRegistration(registration);
|
||||
if (!snapshot) {
|
||||
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
});
|
||||
}
|
||||
// BT-PRE-04. The vault owns its own registration invariants so a second
|
||||
// issuer adapter, a test seam or composition code cannot register a
|
||||
// weaker capability of the same type. The HTTP decoder still owns the
|
||||
// wire shape; this only re-checks runtime invariants.
|
||||
const invalid = validatePresignedCapabilityRegistration(snapshot);
|
||||
if (invalid) return invalid;
|
||||
pruneExpired();
|
||||
if (
|
||||
byReceipt.has(registration.capabilityReceipt) ||
|
||||
@@ -133,36 +291,37 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
}
|
||||
|
||||
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,
|
||||
capabilityReceipt: snapshot.capabilityReceipt,
|
||||
method: snapshot.method,
|
||||
binding: freezeBinding(snapshot.binding),
|
||||
mediaType: snapshot.mediaType,
|
||||
byteLength: snapshot.byteLength,
|
||||
maxBytes: snapshot.maxBytes,
|
||||
expectedSha256: snapshot.expectedSha256,
|
||||
expiresAtEpochMs: snapshot.expiresAtEpochMs,
|
||||
}) as PresignedTransferCapability;
|
||||
const binding: PresignedCapabilityBinding = Object.freeze({
|
||||
capability,
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: capability.capabilityReceipt,
|
||||
method: capability.method,
|
||||
binding: capability.binding,
|
||||
href: registration.href,
|
||||
origin: registration.origin,
|
||||
path: registration.path,
|
||||
href: snapshot.href,
|
||||
origin: snapshot.origin,
|
||||
path: snapshot.path,
|
||||
allowedQueryParameters: Object.freeze([
|
||||
...registration.allowedQueryParameters,
|
||||
...snapshot.allowedQueryParameters,
|
||||
]),
|
||||
requestHeaders: freezeHeaders(registration.requestHeaders),
|
||||
requestHeaders: freezeHeaders(snapshot.requestHeaders),
|
||||
requiredResponseHeaders: freezeHeaders(
|
||||
registration.requiredResponseHeaders,
|
||||
snapshot.requiredResponseHeaders,
|
||||
),
|
||||
digestRequestHeader: registration.digestRequestHeader,
|
||||
digestResponseHeader: registration.digestResponseHeader,
|
||||
receiptResponseHeader: registration.receiptResponseHeader,
|
||||
expectedStatus: registration.expectedStatus,
|
||||
digestRequestHeader: snapshot.digestRequestHeader,
|
||||
digestResponseHeader: snapshot.digestResponseHeader,
|
||||
receiptResponseHeader: snapshot.receiptResponseHeader,
|
||||
expectedStatus: snapshot.expectedStatus,
|
||||
expectedResponseByteLength:
|
||||
registration.expectedResponseByteLength,
|
||||
snapshot.expectedResponseByteLength,
|
||||
mediaType: capability.mediaType,
|
||||
byteLength: capability.byteLength,
|
||||
maxBytes: capability.maxBytes,
|
||||
@@ -242,6 +401,95 @@ export function createSingleUsePresignedReplayGuard():
|
||||
});
|
||||
}
|
||||
|
||||
const DOWNLOAD_BINDING_KEYS = Object.freeze(["kind", "resourceId"]);
|
||||
const UPLOAD_BINDING_KEYS = Object.freeze([
|
||||
"kind",
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"uploadBindingSha256",
|
||||
"partNumber",
|
||||
"offset",
|
||||
"idempotencyKey",
|
||||
]);
|
||||
|
||||
/**
|
||||
* TR-01. Copies a registration and everything nested inside it into owned,
|
||||
* frozen values, reading each property exactly once. Only this snapshot is
|
||||
* validated and stored, so a stateful issuer cannot show one value to the
|
||||
* forbidden-header and HTTPS checks and hand another to the vault. A hostile
|
||||
* trap, an accessor, an inherited or extra field and a non-iterable header
|
||||
* array all resolve to `null` — a typed `POLICY_REJECTED` — rather than
|
||||
* escaping as a native exception.
|
||||
*/
|
||||
function snapshotRegistration(
|
||||
source: unknown,
|
||||
): PresignedCapabilityRegistration | null {
|
||||
const outer = snapshotExactObject(source, {
|
||||
allowed: REGISTRATION_KEYS,
|
||||
required: REGISTRATION_KEYS,
|
||||
});
|
||||
if (outer === null) return null;
|
||||
|
||||
const binding = snapshotBinding(outer["binding"]);
|
||||
if (binding === null) return null;
|
||||
const allowedQueryParameters = snapshotExactArray(
|
||||
outer["allowedQueryParameters"],
|
||||
);
|
||||
if (allowedQueryParameters === null) return null;
|
||||
const requestHeaders = snapshotHeaderBindings(outer["requestHeaders"]);
|
||||
const requiredResponseHeaders = snapshotHeaderBindings(
|
||||
outer["requiredResponseHeaders"],
|
||||
);
|
||||
if (requestHeaders === null || requiredResponseHeaders === null) return null;
|
||||
|
||||
return Object.freeze({
|
||||
...outer,
|
||||
binding,
|
||||
allowedQueryParameters: Object.freeze([...allowedQueryParameters]),
|
||||
requestHeaders,
|
||||
requiredResponseHeaders,
|
||||
}) as PresignedCapabilityRegistration;
|
||||
}
|
||||
|
||||
function snapshotBinding(source: unknown): PresignedTransferBinding | null {
|
||||
const kind = ownDataValue(source, "kind");
|
||||
if (kind !== "DOWNLOAD" && kind !== "UPLOAD_PART") return null;
|
||||
const keys =
|
||||
kind === "DOWNLOAD" ? DOWNLOAD_BINDING_KEYS : UPLOAD_BINDING_KEYS;
|
||||
const binding = snapshotExactObject(source, {
|
||||
allowed: keys,
|
||||
required: keys,
|
||||
});
|
||||
return binding === null
|
||||
? null
|
||||
: (binding as unknown as PresignedTransferBinding);
|
||||
}
|
||||
|
||||
function snapshotHeaderBindings(
|
||||
source: unknown,
|
||||
): readonly PresignedHeaderBinding[] | null {
|
||||
const rows = snapshotExactArray(source);
|
||||
if (rows === null) return null;
|
||||
const headers: PresignedHeaderBinding[] = [];
|
||||
for (const row of rows) {
|
||||
const header = snapshotExactObject(row, {
|
||||
allowed: ["name", "value"],
|
||||
required: ["name", "value"],
|
||||
});
|
||||
if (
|
||||
header === null ||
|
||||
typeof header["name"] !== "string" ||
|
||||
header["name"].length === 0 ||
|
||||
typeof header["value"] !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
headers.push(header as unknown as PresignedHeaderBinding);
|
||||
}
|
||||
return Object.freeze(headers);
|
||||
}
|
||||
|
||||
function freezeBinding(
|
||||
binding: PresignedTransferBinding,
|
||||
): PresignedTransferBinding {
|
||||
|
||||
@@ -8,6 +8,11 @@ import type {
|
||||
PresignedUploadPartOutcome,
|
||||
PresignedUploadPartPort,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortTimerSnapshot,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
@@ -91,16 +96,20 @@ export function createPresignedTransferExecutor(
|
||||
const timeoutMs = positiveSafeInteger(options.timeoutMs);
|
||||
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
|
||||
const now = options.now ?? Date.now;
|
||||
const scheduler =
|
||||
// X-AUDIT-02. The timer callables are captured once, bound to their
|
||||
// receiver, so replacing a scheduler method after composition cannot change
|
||||
// how work already in flight is bounded.
|
||||
const timers = snapshotAbortTimers(
|
||||
options.scheduler ??
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler);
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler),
|
||||
);
|
||||
const createVerifier =
|
||||
options.createStreamingVerifier ?? createStreamingSha256Verifier;
|
||||
if (options.digestBytes === undefined && !globalThis.crypto?.subtle) {
|
||||
@@ -154,41 +163,40 @@ export function createPresignedTransferExecutor(
|
||||
const consumed = consume(capability);
|
||||
if (!consumed.ok) return consumed;
|
||||
|
||||
const scope = createAbortScope(signal, timeoutMs, scheduler);
|
||||
try {
|
||||
const response = await fetcher(binding.href, {
|
||||
method: "GET",
|
||||
headers: headersFor(binding),
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
});
|
||||
const validated = validateDownloadResponse(
|
||||
response,
|
||||
binding,
|
||||
);
|
||||
if (!validated.ok) {
|
||||
cancelBody(response);
|
||||
scope.release();
|
||||
return validated;
|
||||
}
|
||||
const source = createDownloadSource({
|
||||
response,
|
||||
binding,
|
||||
capability,
|
||||
externalSignal: signal,
|
||||
scope,
|
||||
hardMaxChunkBytes,
|
||||
createVerifier,
|
||||
observer,
|
||||
});
|
||||
return browserDataSuccess(source);
|
||||
} catch {
|
||||
scope.release();
|
||||
return transferFailure(signal, scope.timedOut());
|
||||
}
|
||||
// BT-PRE-01. The lease is lazy and single-start: `open()` performs no
|
||||
// network I/O, so the transfer deadline begins at first consumption and an
|
||||
// unused source can be discarded through `close()` without leaking a body,
|
||||
// a timer or a listener.
|
||||
const source = createDownloadSource({
|
||||
start: async (scope) =>
|
||||
await fetcher(binding.href, {
|
||||
method: "GET",
|
||||
headers: headersFor(binding),
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
}),
|
||||
validateResponse: (response) =>
|
||||
validateDownloadResponse(response, binding),
|
||||
createScope: (consumerSignal?: AbortSignal) =>
|
||||
createAbortScope(
|
||||
signal,
|
||||
timeoutMs,
|
||||
timers,
|
||||
consumerSignal ? [consumerSignal] : [],
|
||||
),
|
||||
recheckExpiry: () =>
|
||||
validateExpiry(capability, minimumRemainingLifetimeMs, now()),
|
||||
binding,
|
||||
capability,
|
||||
externalSignal: signal,
|
||||
hardMaxChunkBytes,
|
||||
createVerifier,
|
||||
observer,
|
||||
});
|
||||
return browserDataSuccess(source);
|
||||
}
|
||||
|
||||
async function putUploadPart(
|
||||
@@ -273,47 +281,67 @@ export function createPresignedTransferExecutor(
|
||||
) {
|
||||
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
let actualDigest: string;
|
||||
// TR-RR-02. The abort scope is created first, so the digest — which can be
|
||||
// a long or non-settling computation over a large buffer — is owned by the
|
||||
// caller signal and the deadline like every other step. Computing it before
|
||||
// the scope existed meant an abort or a deadline could not reach it, and a
|
||||
// hash that never settled held the whole `put` open.
|
||||
const scope = createAbortScope(request.signal, timeoutMs, timers);
|
||||
try {
|
||||
actualDigest = normalizedSha256(await digestBytes(bytes));
|
||||
} catch {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"PRESIGNED_TRANSFER",
|
||||
const digested = await scope.race(
|
||||
Promise.resolve().then(async () => await digestBytes(bytes)),
|
||||
);
|
||||
}
|
||||
if (actualDigest !== capability.expectedSha256) {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"PRESIGNED_TRANSFER",
|
||||
if (digested === SCOPE_ENDED) {
|
||||
return transferFailure(request.signal, scope.timedOut());
|
||||
}
|
||||
let actualDigest: string;
|
||||
try {
|
||||
actualDigest = normalizedSha256(digested);
|
||||
} catch {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"PRESIGNED_TRANSFER",
|
||||
);
|
||||
}
|
||||
if (actualDigest !== capability.expectedSha256) {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"PRESIGNED_TRANSFER",
|
||||
);
|
||||
}
|
||||
// The owner is re-checked after the wait: a claim and a network call may
|
||||
// only follow a digest that finished while this operation still held the
|
||||
// execution.
|
||||
if (scope.signal.aborted || request.signal.aborted) {
|
||||
return transferFailure(request.signal, scope.timedOut());
|
||||
}
|
||||
const active = validateExpiry(
|
||||
capability,
|
||||
minimumRemainingLifetimeMs,
|
||||
now(),
|
||||
);
|
||||
}
|
||||
if (request.signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
const active = validateExpiry(
|
||||
capability,
|
||||
minimumRemainingLifetimeMs,
|
||||
now(),
|
||||
);
|
||||
if (!active.ok) return active;
|
||||
const claimed = claim(capability);
|
||||
if (!claimed.ok) return claimed;
|
||||
const consumed = consume(capability);
|
||||
if (!consumed.ok) return consumed;
|
||||
if (!active.ok) return active;
|
||||
const claimed = claim(capability);
|
||||
if (!claimed.ok) return claimed;
|
||||
const consumed = consume(capability);
|
||||
if (!consumed.ok) return consumed;
|
||||
|
||||
const scope = createAbortScope(request.signal, timeoutMs, scheduler);
|
||||
try {
|
||||
const response = await fetcher(binding.href, {
|
||||
method: "PUT",
|
||||
headers: headersFor(binding),
|
||||
body: bytes.buffer,
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
});
|
||||
const raced = await scope.race(
|
||||
fetcher(binding.href, {
|
||||
method: "PUT",
|
||||
headers: headersFor(binding),
|
||||
body: bytes.buffer,
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
}),
|
||||
);
|
||||
if (raced === SCOPE_ENDED) {
|
||||
return transferFailure(request.signal, scope.timedOut());
|
||||
}
|
||||
const response = raced;
|
||||
const validated = validateUploadResponse(
|
||||
response,
|
||||
binding,
|
||||
@@ -406,29 +434,61 @@ export function createPresignedTransferExecutor(
|
||||
}
|
||||
|
||||
function createDownloadSource(input: Readonly<{
|
||||
response: Response;
|
||||
start: (
|
||||
scope: ReturnType<typeof createAbortScope>,
|
||||
) => Promise<Response>;
|
||||
validateResponse: (response: Response) => BrowserDataResult<unknown>;
|
||||
createScope: (
|
||||
consumerSignal?: AbortSignal,
|
||||
) => ReturnType<typeof createAbortScope>;
|
||||
recheckExpiry: () => BrowserDataResult<unknown>;
|
||||
binding: PresignedCapabilityBinding;
|
||||
capability: PresignedDownloadCapability;
|
||||
externalSignal: AbortSignal;
|
||||
scope: ReturnType<typeof createAbortScope>;
|
||||
hardMaxChunkBytes: number;
|
||||
createVerifier: (
|
||||
expectedSha256: string,
|
||||
) => StreamingSha256Verifier;
|
||||
observer: BrowserDataObserver | undefined;
|
||||
}>): PresignedDownloadByteSource {
|
||||
let started = false;
|
||||
/** BT-PRE-01. One state machine shared by `stream()` and `close()`. */
|
||||
let state: "READY" | "STREAMING" | "CLOSED" = "READY";
|
||||
let activeScope: ReturnType<typeof createAbortScope> | undefined;
|
||||
let activeResponse: Response | undefined;
|
||||
|
||||
// TR-RR-01. Releasing the scope only dropped listeners and the timer, so a
|
||||
// fetch or read already in flight kept running after `close()`. The scope is
|
||||
// aborted here, which is what actually ends the physical I/O.
|
||||
const releaseActive = () => {
|
||||
if (activeScope) {
|
||||
activeScope.abort();
|
||||
}
|
||||
if (activeResponse) {
|
||||
cancelBody(activeResponse);
|
||||
activeResponse = undefined;
|
||||
}
|
||||
if (activeScope) {
|
||||
activeScope.release();
|
||||
activeScope = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
byteLength: input.capability.byteLength,
|
||||
capability: input.capability,
|
||||
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
|
||||
close() {
|
||||
// READY -> CLOSED performs no I/O; STREAMING -> CLOSED cancels once.
|
||||
if (state === "CLOSED") return;
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
},
|
||||
async *stream(
|
||||
consumerSignal: AbortSignal,
|
||||
): AsyncIterable<BrowserDataResult<Uint8Array>> {
|
||||
if (!isAbortSignal(consumerSignal)) {
|
||||
started = true;
|
||||
cancelBody(input.response);
|
||||
input.scope.release();
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = browserDataFailure(
|
||||
"INVALID_INPUT",
|
||||
"PRESIGNED_TRANSFER",
|
||||
@@ -437,7 +497,7 @@ function createDownloadSource(input: Readonly<{
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
if (started) {
|
||||
if (state !== "READY") {
|
||||
const failure = browserDataFailure(
|
||||
"CONFLICT",
|
||||
"PRESIGNED_TRANSFER",
|
||||
@@ -449,10 +509,72 @@ function createDownloadSource(input: Readonly<{
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
let combined:
|
||||
| ReturnType<typeof combineConsumerAbort>
|
||||
| undefined;
|
||||
state = "STREAMING";
|
||||
// The capability may have expired while the lease sat unused.
|
||||
const stillActive = input.recheckExpiry();
|
||||
if (!stillActive.ok) {
|
||||
state = "CLOSED";
|
||||
observeTransferResult(
|
||||
input.observer,
|
||||
"DOWNLOAD",
|
||||
stillActive,
|
||||
0,
|
||||
);
|
||||
yield stillActive as BrowserDataResult<never>;
|
||||
return;
|
||||
}
|
||||
// TR-RR-01. The consumer's stream signal is part of this operation's
|
||||
// ownership from the start. Composing it only after the fetch had begun
|
||||
// meant an already-aborted consumer still caused one network request.
|
||||
const scope = input.createScope(consumerSignal);
|
||||
activeScope = scope;
|
||||
if (scope.signal.aborted) {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = transferFailure(
|
||||
input.externalSignal,
|
||||
scope.timedOut(),
|
||||
);
|
||||
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
// BT-PRE-03. A fetch that ignores its signal cannot outlive the scope.
|
||||
const started = await scope.race(input.start(scope));
|
||||
if (started === SCOPE_ENDED) {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = transferFailure(
|
||||
input.externalSignal,
|
||||
scope.timedOut(),
|
||||
);
|
||||
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
response = started;
|
||||
} catch {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = transferFailure(
|
||||
input.externalSignal,
|
||||
scope.timedOut(),
|
||||
);
|
||||
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
activeResponse = response;
|
||||
const validated = input.validateResponse(response);
|
||||
if (!validated.ok) {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
observeTransferResult(input.observer, "DOWNLOAD", validated, 0);
|
||||
yield validated as BrowserDataResult<never>;
|
||||
return;
|
||||
}
|
||||
let reader:
|
||||
| ReadableStreamDefaultReader<Uint8Array>
|
||||
| undefined;
|
||||
@@ -470,14 +592,10 @@ function createDownloadSource(input: Readonly<{
|
||||
return browserDataFailure(code, "PRESIGNED_TRANSFER", options);
|
||||
};
|
||||
try {
|
||||
combined = combineConsumerAbort(
|
||||
input.scope,
|
||||
consumerSignal,
|
||||
);
|
||||
const verifier = input.createVerifier(
|
||||
input.capability.expectedSha256,
|
||||
);
|
||||
if (!input.response.body) {
|
||||
if (!response.body) {
|
||||
let verified = false;
|
||||
try {
|
||||
verified =
|
||||
@@ -492,7 +610,7 @@ function createDownloadSource(input: Readonly<{
|
||||
yield fail("INTEGRITY_FAILED");
|
||||
return;
|
||||
}
|
||||
reader = input.response.body.getReader();
|
||||
reader = response.body.getReader();
|
||||
while (true) {
|
||||
if (
|
||||
input.externalSignal.aborted ||
|
||||
@@ -501,7 +619,7 @@ function createDownloadSource(input: Readonly<{
|
||||
yield fail("ABORTED");
|
||||
return;
|
||||
}
|
||||
if (input.scope.timedOut()) {
|
||||
if (scope.timedOut()) {
|
||||
yield fail("UNAVAILABLE", {
|
||||
retryable: true,
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
@@ -510,7 +628,7 @@ function createDownloadSource(input: Readonly<{
|
||||
}
|
||||
const result = await readWithSignal(
|
||||
reader,
|
||||
input.scope.signal,
|
||||
scope.signal,
|
||||
);
|
||||
if (result.done) break;
|
||||
const chunk = result.value;
|
||||
@@ -544,7 +662,7 @@ function createDownloadSource(input: Readonly<{
|
||||
yield fail("ABORTED");
|
||||
return;
|
||||
}
|
||||
if (input.scope.timedOut()) {
|
||||
if (scope.timedOut()) {
|
||||
yield fail("UNAVAILABLE", {
|
||||
retryable: true,
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
@@ -590,7 +708,7 @@ function createDownloadSource(input: Readonly<{
|
||||
consumerSignal.aborted
|
||||
) {
|
||||
yield fail("ABORTED");
|
||||
} else if (input.scope.timedOut()) {
|
||||
} else if (scope.timedOut()) {
|
||||
yield fail("UNAVAILABLE", {
|
||||
retryable: true,
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
@@ -602,17 +720,20 @@ function createDownloadSource(input: Readonly<{
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
combined?.release();
|
||||
if (!completed) {
|
||||
if (reader) cancelReader(reader);
|
||||
else cancelBody(input.response);
|
||||
else cancelBody(response);
|
||||
}
|
||||
try {
|
||||
reader?.releaseLock();
|
||||
} catch {
|
||||
// Reader cleanup cannot change stream success or failure.
|
||||
}
|
||||
input.scope.release();
|
||||
// The lease is terminal once its single stream ends; cleanup runs once.
|
||||
state = "CLOSED";
|
||||
activeResponse = undefined;
|
||||
activeScope = undefined;
|
||||
scope.release();
|
||||
observeBrowserData(input.observer, {
|
||||
operation: "DOWNLOAD",
|
||||
outcome: completed
|
||||
@@ -925,50 +1046,85 @@ function transferFailure(
|
||||
);
|
||||
}
|
||||
|
||||
/** BT-PRE-03. The scope ended before the task settled. */
|
||||
const SCOPE_ENDED = Symbol("presigned-scope-ended");
|
||||
|
||||
function compensateLateResponse(task: Promise<unknown>): void {
|
||||
void task
|
||||
.then(async (value) => {
|
||||
const body = (value as { body?: { cancel(): Promise<void> } | null })
|
||||
?.body;
|
||||
await body?.cancel();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-RR-01 / TR-RR-05. A projection of the shared `createAbortableOperation`
|
||||
* primitive into this subsystem's vocabulary, replacing a second hand-written
|
||||
* copy of the same mechanics.
|
||||
*
|
||||
* `additionalSignals` lets a consumer's stream signal join the operation's
|
||||
* ownership before any I/O begins, and `abort()` ends the physical work rather
|
||||
* than only releasing bookkeeping.
|
||||
*/
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: Scheduler,
|
||||
timers: AbortTimerSnapshot,
|
||||
additionalSignals: readonly AbortSignal[] = [],
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let released = false;
|
||||
const onAbort = () => controller.abort(external.reason);
|
||||
const releaseListener = () => {
|
||||
external.removeEventListener("abort", onAbort);
|
||||
};
|
||||
external.addEventListener("abort", onAbort, { once: true });
|
||||
if (external.aborted) onAbort();
|
||||
const timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
releaseListener();
|
||||
}, timeoutMs);
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
abort(reason?: unknown) {
|
||||
controller.abort(reason);
|
||||
},
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
scheduler.clearTimeout(timer);
|
||||
releaseListener();
|
||||
},
|
||||
const operation = createAbortableOperation({
|
||||
signal: external,
|
||||
timeoutMs,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
}
|
||||
|
||||
function combineConsumerAbort(
|
||||
scope: ReturnType<typeof createAbortScope>,
|
||||
consumer: AbortSignal,
|
||||
) {
|
||||
const onAbort = () => scope.abort(consumer.reason);
|
||||
consumer.addEventListener("abort", onAbort, { once: true });
|
||||
if (consumer.aborted) onAbort();
|
||||
const releases: (() => void)[] = [];
|
||||
for (const extra of additionalSignals) {
|
||||
if (!extra) continue;
|
||||
if (extra.aborted) {
|
||||
operation.close();
|
||||
continue;
|
||||
}
|
||||
const onAbort = () => operation.close();
|
||||
try {
|
||||
extra.addEventListener("abort", onAbort, { once: true });
|
||||
releases.push(() => {
|
||||
try {
|
||||
extra.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
operation.close();
|
||||
}
|
||||
}
|
||||
const releaseExtras = () => {
|
||||
for (const release of releases.splice(0)) release();
|
||||
};
|
||||
return Object.freeze({
|
||||
signal: operation.signal,
|
||||
timedOut: () => operation.terminal() === "DEADLINE",
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
const outcome = await operation.race(task, (value) => {
|
||||
const body = (
|
||||
value as { body?: { cancel(): Promise<void> } | null } | null
|
||||
)?.body;
|
||||
void body?.cancel().catch(() => undefined);
|
||||
});
|
||||
if (outcome.kind === "VALUE") return outcome.value;
|
||||
if (outcome.kind === "REJECTED") throw outcome.reason;
|
||||
return SCOPE_ENDED;
|
||||
},
|
||||
abort() {
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
release() {
|
||||
consumer.removeEventListener("abort", onAbort);
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
type AbortRace,
|
||||
type AbortTerminalReason,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import type {
|
||||
ResumableUploadControlOperation,
|
||||
ResumableUploadJsonTransport,
|
||||
@@ -32,6 +37,13 @@ export type ResumableUploadFetchTransportDependencies = Readonly<{
|
||||
expectedSuccessStatuses?: Partial<
|
||||
Readonly<Record<ResumableUploadControlOperation, number>>
|
||||
>;
|
||||
/** BT-UP-02. Injected epoch clock; defaults to `Date.now`. */
|
||||
nowEpochMs?: () => number;
|
||||
/** BT-UP-02. Injected scheduler for the request timeout. */
|
||||
scheduler?: Readonly<{
|
||||
setTimeout(callback: () => void, delayMs: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const DEFAULT_SUCCESS_STATUSES: Readonly<
|
||||
@@ -68,6 +80,37 @@ export function createResumableUploadFetchJsonTransport(
|
||||
throw new TypeError("Upload fetch transport dependency is invalid.");
|
||||
}
|
||||
const headers = snapshotHeaders(input.requestHeaders ?? []);
|
||||
// BT-UP-02. Snapshot and validate the clock and scheduler once.
|
||||
const nowEpochMs = input.nowEpochMs ?? (() => Date.now());
|
||||
/** A broken clock must not produce a negative or NaN retry delay. */
|
||||
const safeNowEpochMs = (): number => {
|
||||
try {
|
||||
const value = nowEpochMs();
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : Number.NaN;
|
||||
} catch {
|
||||
return Number.NaN;
|
||||
}
|
||||
};
|
||||
const scheduler = input.scheduler ?? {
|
||||
setTimeout: (callback: () => void, delayMs: number) =>
|
||||
setTimeout(callback, delayMs),
|
||||
clearTimeout: (handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
if (
|
||||
typeof nowEpochMs !== "function" ||
|
||||
typeof scheduler.setTimeout !== "function" ||
|
||||
typeof scheduler.clearTimeout !== "function"
|
||||
) {
|
||||
throw new TypeError("Upload fetch transport dependency is invalid.");
|
||||
}
|
||||
// X-AUDIT-02. Reading `scheduler.setTimeout` again at request time made the
|
||||
// validated dependency and the executed one two different things: replacing
|
||||
// the method after composition changed how an attempt was bounded. The
|
||||
// callables are bound to their receiver once, here.
|
||||
const setTimer = scheduler.setTimeout.bind(scheduler);
|
||||
const clearTimer = scheduler.clearTimeout.bind(scheduler);
|
||||
const timeoutMs = boundedPositiveInteger(
|
||||
input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
1,
|
||||
@@ -139,8 +182,18 @@ export function createResumableUploadFetchJsonTransport(
|
||||
"NONE",
|
||||
);
|
||||
}
|
||||
const attempt = createFetchAttempt(signal, timeoutMs);
|
||||
const attempt = createFetchAttempt(
|
||||
signal,
|
||||
timeoutMs,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
);
|
||||
try {
|
||||
if (attempt.terminalKind() !== null) {
|
||||
// The attempt was closed before it could be bounded, so no request is
|
||||
// ever put on the wire.
|
||||
return attemptFailure(attempt, operation);
|
||||
}
|
||||
const fetchPromise = fetcher(endpoint, {
|
||||
method: "POST",
|
||||
headers: headersFor(headers),
|
||||
@@ -154,19 +207,13 @@ export function createResumableUploadFetchJsonTransport(
|
||||
? "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") {
|
||||
const raced = await attempt.race(
|
||||
Promise.resolve(fetchPromise),
|
||||
(late) => {
|
||||
cancelResponseBody(late);
|
||||
},
|
||||
);
|
||||
if (raced.kind !== "VALUE") {
|
||||
return attemptFailure(attempt, operation);
|
||||
}
|
||||
const response = raced.value;
|
||||
@@ -188,6 +235,7 @@ export function createResumableUploadFetchJsonTransport(
|
||||
response,
|
||||
operation,
|
||||
maxRetryAfterMs,
|
||||
safeNowEpochMs(),
|
||||
);
|
||||
cancelResponseBody(response);
|
||||
return failed;
|
||||
@@ -390,18 +438,8 @@ async function readBoundedJson(
|
||||
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") {
|
||||
const raced = await attempt.race(reader.read());
|
||||
if (raced.kind !== "VALUE") {
|
||||
cancelReader(reader);
|
||||
return attemptFailure(attempt, operation);
|
||||
}
|
||||
@@ -473,6 +511,7 @@ function statusFailure(
|
||||
response: Response,
|
||||
operation: ResumableUploadControlOperation,
|
||||
maxRetryAfterMs: number,
|
||||
nowEpochMs: number,
|
||||
): UploadProviderResult<never> {
|
||||
if (response.status === 400 || response.status === 422) {
|
||||
return failure("INVALID_INPUT", operation, false, "NONE");
|
||||
@@ -495,6 +534,7 @@ function statusFailure(
|
||||
if (response.status === 429) {
|
||||
const retryAfterMs = parseRetryAfter(
|
||||
response.headers.get("retry-after"),
|
||||
nowEpochMs,
|
||||
);
|
||||
return retryAfterMs !== null && retryAfterMs <= maxRetryAfterMs
|
||||
? failure(
|
||||
@@ -534,50 +574,54 @@ function failure(
|
||||
return Object.freeze({ ok: false, error });
|
||||
}
|
||||
|
||||
type FetchAttemptTerminal =
|
||||
| Readonly<{ kind: "ABORT" }>
|
||||
| Readonly<{ kind: "TIMEOUT" }>;
|
||||
type FetchAttemptTerminalKind = "ABORT" | "TIMEOUT" | "CLOSED";
|
||||
|
||||
type FetchAttempt = Readonly<{
|
||||
signal: AbortSignal;
|
||||
terminal: Promise<FetchAttemptTerminal>;
|
||||
terminalKind(): FetchAttemptTerminal["kind"] | null;
|
||||
terminalKind(): FetchAttemptTerminalKind | null;
|
||||
race<Value>(
|
||||
task: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
): Promise<AbortRace<Value>>;
|
||||
release(): void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* X-AUDIT-02 / TR-RR-05. The attempt is the shared abort primitive with this
|
||||
* subsystem's vocabulary on top. Owning a private copy meant the listener was
|
||||
* attached before the timer was installed, so a scheduler that threw rejected
|
||||
* the public `execute()` promise and left the listener on the caller's signal.
|
||||
*/
|
||||
function createFetchAttempt(
|
||||
parent: AbortSignal,
|
||||
timeoutMs: number,
|
||||
setTimer: (callback: () => void, delayMs: number) => unknown,
|
||||
clearTimer: (handle: unknown) => void,
|
||||
): 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 operation = createAbortableOperation({
|
||||
signal: parent,
|
||||
timeoutMs,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
});
|
||||
const kindOf = (
|
||||
reason: AbortTerminalReason | null,
|
||||
): FetchAttemptTerminalKind | null => {
|
||||
if (reason === null) return null;
|
||||
if (reason === "CALLER_ABORT") return "ABORT";
|
||||
return reason === "DEADLINE" ? "TIMEOUT" : "CLOSED";
|
||||
};
|
||||
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,
|
||||
signal: operation.signal,
|
||||
terminalKind: () => kindOf(operation.terminal()),
|
||||
race: <Value,>(
|
||||
task: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
) => operation.race(task, compensate),
|
||||
release() {
|
||||
clearTimeout(timer);
|
||||
parent.removeEventListener("abort", abort);
|
||||
// BT-UP-01. Cleanup is best effort and must never replace the already
|
||||
// classified terminal result with a rejection.
|
||||
operation.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -623,7 +667,15 @@ function releaseReader(
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfter(value: string | null): number | null {
|
||||
/**
|
||||
* BT-UP-02. Both the delta-seconds and the HTTP-date branch resolve against the
|
||||
* same captured `now`, so a fake clock makes boundary, rollback and invalid-date
|
||||
* behaviour deterministic instead of depending on the global clock.
|
||||
*/
|
||||
function parseRetryAfter(
|
||||
value: string | null,
|
||||
nowEpochMs: number,
|
||||
): number | null {
|
||||
if (!value) return null;
|
||||
if (/^(0|[1-9][0-9]*)$/u.test(value)) {
|
||||
const seconds = Number(value);
|
||||
@@ -631,9 +683,11 @@ function parseRetryAfter(value: string | null): number | null {
|
||||
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp)
|
||||
? Math.max(0, timestamp - Date.now())
|
||||
: null;
|
||||
if (!Number.isFinite(timestamp) || !Number.isSafeInteger(nowEpochMs)) {
|
||||
return null;
|
||||
}
|
||||
// A clock that moved backwards yields zero, never a negative delay.
|
||||
return Math.max(0, timestamp - nowEpochMs);
|
||||
}
|
||||
|
||||
function jsonContentType(value: string | null): boolean {
|
||||
@@ -667,12 +721,18 @@ function boundedPositiveInteger(
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-UP-01. The structural guard must cover every method cleanup will call.
|
||||
* Admitting a signal without `removeEventListener` turned a `finally` into a
|
||||
* Promise rejection instead of the typed terminal result.
|
||||
*/
|
||||
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",
|
||||
typeof (value as AbortSignal).addEventListener === "function" &&
|
||||
typeof (value as AbortSignal).removeEventListener === "function",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
ownDataValue,
|
||||
snapshotExactArray,
|
||||
snapshotExactObject,
|
||||
} from "../../../contracts/exact-snapshot.ts";
|
||||
import {
|
||||
isSafeUploadReceiptToken,
|
||||
isUploadFileFingerprint,
|
||||
@@ -104,7 +109,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
"UPLOAD_SESSION",
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const session = decodeSession(response.value);
|
||||
const session = decodeSafely(decodeSession, response.value);
|
||||
return session
|
||||
? browserDataSuccess(session)
|
||||
: browserDataFailure(
|
||||
@@ -140,7 +145,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
"UPLOAD_RECONCILE",
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const status = decodeStatus(response.value);
|
||||
const status = decodeSafely(decodeStatus, response.value);
|
||||
return status
|
||||
? browserDataSuccess(status)
|
||||
: browserDataFailure(
|
||||
@@ -158,7 +163,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
!SHA256_HEX.test(input.uploadBindingSha256) ||
|
||||
!isUploadFileFingerprint(input.fingerprint) ||
|
||||
!MEDIA_TYPE.test(input.mediaType) ||
|
||||
!isUploadPartReceiptShape(input.part) ||
|
||||
decodeUploadPartShape(input.part) === null ||
|
||||
!safeIdempotencyKey(input.idempotencyKey)
|
||||
) {
|
||||
return browserDataFailure(
|
||||
@@ -270,7 +275,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
"UPLOAD_COMPLETE",
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const completed = decodeCompletion(response.value);
|
||||
const completed = decodeSafely(decodeCompletion, response.value);
|
||||
return completed
|
||||
? browserDataSuccess(completed)
|
||||
: browserDataFailure(
|
||||
@@ -303,15 +308,16 @@ export function createResumableUploadHttpControlPlane(
|
||||
"UPLOAD_ABORT",
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const abortState = exactSnapshot(response.value, ["state"]);
|
||||
if (
|
||||
!exactKeys(response.value, ["state"]) ||
|
||||
typeof response.value.state !== "string" ||
|
||||
!abortState ||
|
||||
typeof abortState["state"] !== "string" ||
|
||||
![
|
||||
"ABORTED",
|
||||
"NOT_FOUND",
|
||||
"EXPIRED",
|
||||
"ALREADY_COMPLETED",
|
||||
].includes(response.value.state)
|
||||
].includes(abortState["state"])
|
||||
) {
|
||||
return browserDataFailure(
|
||||
"CORRUPT_DATA",
|
||||
@@ -321,7 +327,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
}
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: response.value.state as
|
||||
state: abortState["state"] as
|
||||
| "ABORTED"
|
||||
| "NOT_FOUND"
|
||||
| "EXPIRED"
|
||||
@@ -367,66 +373,75 @@ async function invokeJsonTransport(
|
||||
}
|
||||
|
||||
function decodeSession(value: unknown): UploadSession | null {
|
||||
const record = exactSnapshot(value, [
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"fingerprint",
|
||||
"partSizeBytes",
|
||||
"partCount",
|
||||
"maxConcurrency",
|
||||
"expiresAtEpochMs",
|
||||
]);
|
||||
if (!record) return null;
|
||||
const fingerprint = snapshotFingerprint(record["fingerprint"]);
|
||||
const sessionId = record["sessionId"];
|
||||
const requestBindingSha256 = record["requestBindingSha256"];
|
||||
const partSizeBytes = record["partSizeBytes"];
|
||||
const partCount = record["partCount"];
|
||||
const maxConcurrency = record["maxConcurrency"];
|
||||
const expiresAtEpochMs = record["expiresAtEpochMs"];
|
||||
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)
|
||||
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
|
||||
typeof sessionId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(sessionId) ||
|
||||
typeof requestBindingSha256 !== "string" ||
|
||||
!SHA256_HEX.test(requestBindingSha256) ||
|
||||
fingerprint === null ||
|
||||
!positiveSafeInteger(partSizeBytes) ||
|
||||
partSizeBytes !== fingerprint.partSizeBytes ||
|
||||
!positiveSafeInteger(partCount) ||
|
||||
partCount !== fingerprint.partCount ||
|
||||
partCount > MAX_PART_COUNT ||
|
||||
!positiveSafeInteger(maxConcurrency) ||
|
||||
!positiveSafeInteger(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,
|
||||
sessionId,
|
||||
requestBindingSha256,
|
||||
fingerprint,
|
||||
partSizeBytes,
|
||||
partCount,
|
||||
maxConcurrency,
|
||||
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)
|
||||
) {
|
||||
// The discriminator is read from the same snapshot the payload comes from.
|
||||
const state = ownDataValue(value, "state");
|
||||
if (state === "ACTIVE") {
|
||||
const record = exactSnapshot(value, [
|
||||
"state",
|
||||
"session",
|
||||
"acceptedParts",
|
||||
]);
|
||||
if (!record) return null;
|
||||
const session = decodeSession(record["session"]);
|
||||
const rows = snapshotExactArray(record["acceptedParts"]);
|
||||
if (!session || rows === null || rows.length > MAX_RECEIPT_COUNT) {
|
||||
return null;
|
||||
}
|
||||
const parts = Object.freeze(
|
||||
record.acceptedParts.map(snapshotReceipt),
|
||||
);
|
||||
const receipts: UploadPartReceipt[] = [];
|
||||
for (const row of rows) {
|
||||
const receipt = snapshotReceipt(row);
|
||||
if (receipt === null) return null;
|
||||
receipts.push(receipt);
|
||||
}
|
||||
const parts = Object.freeze(receipts);
|
||||
return orderedReceipts(parts, session.fingerprint, false)
|
||||
? Object.freeze({
|
||||
state: "ACTIVE",
|
||||
@@ -435,41 +450,49 @@ function decodeStatus(value: unknown): UploadSessionStatus | null {
|
||||
})
|
||||
: null;
|
||||
}
|
||||
if (
|
||||
record.state === "QUARANTINED" &&
|
||||
exactKeys(record, ["state", "session", "resourceId"])
|
||||
) {
|
||||
const session = decodeSession(record.session);
|
||||
if (state === "QUARANTINED") {
|
||||
const record = exactSnapshot(value, ["state", "session", "resourceId"]);
|
||||
if (!record) return null;
|
||||
const session = decodeSession(record["session"]);
|
||||
const resourceId = record["resourceId"];
|
||||
return session &&
|
||||
typeof record.resourceId === "string" &&
|
||||
SAFE_OPAQUE_ID.test(record.resourceId)
|
||||
typeof resourceId === "string" &&
|
||||
SAFE_OPAQUE_ID.test(resourceId)
|
||||
? Object.freeze({
|
||||
state: "QUARANTINED",
|
||||
session,
|
||||
resourceId: record.resourceId,
|
||||
resourceId,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
if (
|
||||
typeof record.state === "string" &&
|
||||
["ABORTED", "EXPIRED", "NOT_FOUND"].includes(record.state) &&
|
||||
exactKeys(record, [
|
||||
typeof state === "string" &&
|
||||
["ABORTED", "EXPIRED", "NOT_FOUND"].includes(state)
|
||||
) {
|
||||
const record = exactSnapshot(value, [
|
||||
"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)
|
||||
) {
|
||||
]);
|
||||
const sessionId = record?.["sessionId"];
|
||||
const requestBindingSha256 = record?.["requestBindingSha256"];
|
||||
if (
|
||||
!record ||
|
||||
record["state"] !== state ||
|
||||
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
|
||||
typeof sessionId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(sessionId) ||
|
||||
typeof requestBindingSha256 !== "string" ||
|
||||
!SHA256_HEX.test(requestBindingSha256)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
state: record.state as "ABORTED" | "EXPIRED" | "NOT_FOUND",
|
||||
state: state as "ABORTED" | "EXPIRED" | "NOT_FOUND",
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: record.sessionId,
|
||||
requestBindingSha256: record.requestBindingSha256,
|
||||
sessionId,
|
||||
requestBindingSha256,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
@@ -484,34 +507,39 @@ function decodeCompletion(
|
||||
> extends UploadProviderResult<infer Outcome>
|
||||
? Outcome | null
|
||||
: never {
|
||||
const record = exactSnapshot(value, [
|
||||
"state",
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"fingerprint",
|
||||
"resourceId",
|
||||
]);
|
||||
const sessionId = record?.["sessionId"];
|
||||
const requestBindingSha256 = record?.["requestBindingSha256"];
|
||||
const resourceId = record?.["resourceId"];
|
||||
const fingerprint = record ? snapshotFingerprint(record["fingerprint"]) : null;
|
||||
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)
|
||||
!record ||
|
||||
record["state"] !== "QUARANTINED" ||
|
||||
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
|
||||
typeof sessionId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(sessionId) ||
|
||||
typeof requestBindingSha256 !== "string" ||
|
||||
!SHA256_HEX.test(requestBindingSha256) ||
|
||||
fingerprint === null ||
|
||||
typeof resourceId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(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,
|
||||
sessionId,
|
||||
requestBindingSha256,
|
||||
fingerprint,
|
||||
resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -546,52 +574,101 @@ function orderedReceipts(
|
||||
});
|
||||
}
|
||||
|
||||
function isUploadPartReceiptShape(
|
||||
function decodeUploadPartShape(
|
||||
value: unknown,
|
||||
): value is Readonly<{
|
||||
): 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)
|
||||
);
|
||||
}> | null {
|
||||
const record = exactSnapshot(value, [
|
||||
"partNumber",
|
||||
"offset",
|
||||
"byteLength",
|
||||
"checksumSha256",
|
||||
]);
|
||||
if (!record) return null;
|
||||
const partNumber = record["partNumber"];
|
||||
const offset = record["offset"];
|
||||
const byteLength = record["byteLength"];
|
||||
const checksumSha256 = record["checksumSha256"];
|
||||
return positiveSafeInteger(partNumber) &&
|
||||
nonNegativeSafeInteger(offset) &&
|
||||
positiveSafeInteger(byteLength) &&
|
||||
typeof checksumSha256 === "string" &&
|
||||
SHA256_HEX.test(checksumSha256)
|
||||
? Object.freeze({ partNumber, offset, byteLength, checksumSha256 })
|
||||
: null;
|
||||
}
|
||||
|
||||
function snapshotFingerprint(
|
||||
value: UploadFileFingerprint,
|
||||
): UploadFileFingerprint {
|
||||
return Object.freeze({ ...value });
|
||||
/**
|
||||
* TR-05. Copies the nested value first, then validates the copy, so the
|
||||
* fingerprint the session is checked against is the one it carries.
|
||||
*/
|
||||
function snapshotFingerprint(value: unknown): UploadFileFingerprint | null {
|
||||
const record = exactSnapshot(value, [
|
||||
"algorithm",
|
||||
"digestHex",
|
||||
"byteLength",
|
||||
"partSizeBytes",
|
||||
"partCount",
|
||||
]);
|
||||
return record && isUploadFileFingerprint(record)
|
||||
? (record as unknown as UploadFileFingerprint)
|
||||
: null;
|
||||
}
|
||||
|
||||
function snapshotReceipt(value: UploadPartReceipt): UploadPartReceipt {
|
||||
return Object.freeze({ ...value });
|
||||
function snapshotReceipt(value: unknown): UploadPartReceipt | null {
|
||||
const record = exactSnapshot(value, [
|
||||
"partNumber",
|
||||
"offset",
|
||||
"byteLength",
|
||||
"checksumSha256",
|
||||
"receiptToken",
|
||||
]);
|
||||
return record && isUploadPartReceipt(record)
|
||||
? (record as unknown as UploadPartReceipt)
|
||||
: null;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
/**
|
||||
* TR-RR-08 / TR-05. Copies the value into an owned frozen record, reading every
|
||||
* property exactly once, and returns `null` for anything that is not an exact
|
||||
* own-data shape.
|
||||
*
|
||||
* `Object.keys` saw only enumerable own string keys, so a symbol or
|
||||
* non-enumerable extra field passed unseen and a later property read invoked
|
||||
* whatever accessor the sender installed. Worse, checking the sender's object
|
||||
* and then reading it again to build the result let a stateful answer show a
|
||||
* safe `sessionId` to the regex and hand an unvalidated one to the receipt, so
|
||||
* the value that was checked and the value that was returned differed.
|
||||
*/
|
||||
function exactSnapshot(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
): Record<string, unknown> | null {
|
||||
if (Array.isArray(value)) return null;
|
||||
return snapshotExactObject(value, {
|
||||
allowed: keys,
|
||||
required: keys,
|
||||
}) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-RR-08. Runs a decoder inside the adapter's failure boundary. A hostile
|
||||
* object that still throws from a trap becomes a typed `CORRUPT_DATA` result
|
||||
* rather than a native rejection out of a public method.
|
||||
*/
|
||||
function decodeSafely<Value>(
|
||||
decode: (value: unknown) => Value | null,
|
||||
value: unknown,
|
||||
): Value | null {
|
||||
try {
|
||||
return decode(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ResumableUploadCheckpointAdmin,
|
||||
ResumableUploadCheckpoint,
|
||||
ResumableUploadCheckpointStore,
|
||||
PartitionDeleteOutcome,
|
||||
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
@@ -21,6 +22,24 @@ const GOVERNANCE_STORE = "governance";
|
||||
const GOVERNANCE_KEY = "scope-binding";
|
||||
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* BT-UP-03. Per-realm registry keyed by `(IDBFactory identity, databaseName)`.
|
||||
* It prevents this realm from recreating a store whose deletion is still in
|
||||
* flight. It deliberately claims nothing about other realms, which are handled
|
||||
* by native blocked ordering and explicit recovery.
|
||||
*/
|
||||
const PENDING_DELETIONS = new WeakMap<object, Set<string>>();
|
||||
|
||||
function pendingDeletionsFor(factory: unknown): Set<string> {
|
||||
const key = (factory ?? PENDING_DELETIONS) as object;
|
||||
let pending = PENDING_DELETIONS.get(key);
|
||||
if (!pending) {
|
||||
pending = new Set<string>();
|
||||
PENDING_DELETIONS.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export type IndexedDbUploadCheckpointScope = Readonly<{
|
||||
authorityToken: string;
|
||||
namespaceToken: string;
|
||||
@@ -93,6 +112,14 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
? factory.deleteDatabase.bind(factory)
|
||||
: undefined;
|
||||
const databaseName = uploadCheckpointDatabaseName(scope);
|
||||
const pendingDeletions = pendingDeletionsFor(factory);
|
||||
if (pendingDeletions.has(databaseName)) {
|
||||
// BT-UP-03. A deletion dispatched by this realm has not settled, so a new
|
||||
// store over the same database would race an unknown native effect.
|
||||
throw new TypeError(
|
||||
"Upload checkpoint partition has an unresolved pending deletion.",
|
||||
);
|
||||
}
|
||||
const expectedBinding: ScopeBinding = Object.freeze({
|
||||
key: GOVERNANCE_KEY,
|
||||
schemaVersion: 1,
|
||||
@@ -376,7 +403,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
async deletePartition(
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
BrowserDataResult<Readonly<{ state: "DELETED" }>>
|
||||
BrowserDataResult<PartitionDeleteOutcome>
|
||||
> {
|
||||
if (signal?.aborted) {
|
||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
||||
@@ -397,46 +424,64 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
} 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(() => {
|
||||
// BT-UP-03. Once dispatched the deletion may still commit after this
|
||||
// call returns, so the pending registration is installed before the
|
||||
// promise settles and is only released by the real native completion.
|
||||
pendingDeletions.add(databaseName);
|
||||
return await new Promise<BrowserDataResult<PartitionDeleteOutcome>>(
|
||||
(resolve) => {
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (
|
||||
result: BrowserDataResult<PartitionDeleteOutcome>,
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (blockedTimer) clearTimeout(blockedTimer);
|
||||
resolve(result);
|
||||
};
|
||||
const releasePending = () => {
|
||||
pendingDeletions.delete(databaseName);
|
||||
};
|
||||
// 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(() => {
|
||||
// Not NOT_APPLIED: the request is still live in the browser.
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: "PENDING" as const,
|
||||
effect: "UNKNOWN" as const,
|
||||
reason: "BLOCKED_DEADLINE" as const,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, blockedTimeoutMs);
|
||||
};
|
||||
request.onerror = () => {
|
||||
releasePending();
|
||||
finish(
|
||||
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", {
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
}),
|
||||
mapBrowserDataException(
|
||||
request.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
),
|
||||
);
|
||||
}, blockedTimeoutMs);
|
||||
};
|
||||
request.onerror = () =>
|
||||
finish(
|
||||
mapBrowserDataException(
|
||||
request.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
),
|
||||
);
|
||||
request.onsuccess = () =>
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({ state: "DELETED" as const }),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
releasePending();
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: "DELETED" as const,
|
||||
effect: "APPLIED" as const,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
const admin = Object.freeze(adminValue);
|
||||
|
||||
@@ -43,6 +43,15 @@ export function createPresignedUploadPartExecutor(
|
||||
recovery: "RESUME",
|
||||
});
|
||||
}
|
||||
// BT-UP-04. A NaN or negative clock silently bypasses every expiry
|
||||
// comparison, and an infinite one misreports a dependency failure as a
|
||||
// capability policy failure. Both are dependency failures.
|
||||
if (!Number.isSafeInteger(nowEpochMs) || nowEpochMs < 0) {
|
||||
return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!capability ||
|
||||
capability.method !== "PUT" ||
|
||||
|
||||
@@ -66,7 +66,24 @@ import type { UploadMutationLock } from "./upload-mutation-lock.ts";
|
||||
|
||||
export type ResumableUploadRuntime = ResumableUploadPort &
|
||||
Readonly<{
|
||||
/**
|
||||
* BT-UP-06. Compatibility facade: closes admission and starts the same
|
||||
* single-flight drain that `dispose()` awaits.
|
||||
*/
|
||||
close(): void;
|
||||
/**
|
||||
* BT-UP-06. Awaitable teardown for a future composition owner. It shares
|
||||
* one drain promise, aborts the active operation registry and only then
|
||||
* closes the checkpoint store and cancellation channel, so success actually
|
||||
* means quiescent. No current bootstrap consumer is assumed.
|
||||
*/
|
||||
/**
|
||||
* TR-RR-06. Closes admission and returns the *bounded* drain result. A
|
||||
* failure means the runtime is still `CLOSING`: physical work the caller
|
||||
* must not treat as finished is still in flight.
|
||||
*/
|
||||
dispose(): Promise<BrowserDataResult<void>>;
|
||||
lifecycle(): "OPEN" | "CLOSING" | "CLOSED";
|
||||
}>;
|
||||
|
||||
export type ResumableUploadRuntimeDependencies<Capability> = Readonly<{
|
||||
@@ -108,6 +125,13 @@ type RuntimeDependencies<Capability> = Readonly<{
|
||||
random(): number;
|
||||
sleep(delayMs: number, signal: AbortSignal): Promise<void>;
|
||||
observer?: BrowserDataObserver;
|
||||
/**
|
||||
* TR-04. Every raw provider promise, from the moment the collaborator is
|
||||
* called until it actually settles. The wrapper that bounds the attempt can
|
||||
* settle long before the provider does, so the wrapper registry alone could
|
||||
* report an empty set while physical work was still running.
|
||||
*/
|
||||
physicalTasks: Set<Promise<unknown>>;
|
||||
}>;
|
||||
|
||||
type ActiveResolution =
|
||||
@@ -159,10 +183,16 @@ const RECOVERIES: ReadonlySet<string> = new Set([
|
||||
export function createResumableUploadRuntime<Capability>(
|
||||
inputDependencies: ResumableUploadRuntimeDependencies<Capability>,
|
||||
): ResumableUploadRuntime {
|
||||
const dependencies = snapshotDependencies(inputDependencies);
|
||||
/** TR-04. Raw provider work, tracked independently of its bounded wrapper. */
|
||||
const physicalTasks = new Set<Promise<unknown>>();
|
||||
const dependencies = snapshotDependencies(inputDependencies, physicalTasks);
|
||||
const lifetime = new AbortController();
|
||||
const localUploads = new Map<string, Set<AbortController>>();
|
||||
/** BT-UP-06. Terminal settlement of every admitted operation. */
|
||||
const activeOperations = new Set<Promise<unknown>>();
|
||||
let closed = false;
|
||||
let lifecycle: "OPEN" | "CLOSING" | "CLOSED" = "OPEN";
|
||||
let drain: Promise<BrowserDataResult<void>> | null = null;
|
||||
const cancelLocalUploads = (uploadKey: string): void => {
|
||||
if (!SAFE_UPLOAD_KEY.test(uploadKey)) return;
|
||||
for (const controller of localUploads.get(uploadKey) ?? []) {
|
||||
@@ -211,19 +241,24 @@ export function createResumableUploadRuntime<Capability>(
|
||||
}
|
||||
return browserDataFailure("ABORTED", "UPLOAD_SESSION");
|
||||
}
|
||||
const operation = dependencies.mutationLock.run(
|
||||
request.uploadKey,
|
||||
operationScope.signal,
|
||||
async () =>
|
||||
await executeUpload(
|
||||
dependencies,
|
||||
Object.freeze({
|
||||
...request,
|
||||
signal: operationScope.signal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Tracked until terminal settlement so dispose() can prove quiescence.
|
||||
const tracked = Promise.resolve(operation).catch(() => undefined);
|
||||
activeOperations.add(tracked);
|
||||
void tracked.finally(() => activeOperations.delete(tracked));
|
||||
try {
|
||||
return await dependencies.mutationLock.run(
|
||||
request.uploadKey,
|
||||
operationScope.signal,
|
||||
async () =>
|
||||
await executeUpload(
|
||||
dependencies,
|
||||
Object.freeze({
|
||||
...request,
|
||||
signal: operationScope.signal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return await operation;
|
||||
} catch (error) {
|
||||
return mapLockFailure(error, "UPLOAD_SESSION");
|
||||
} finally {
|
||||
@@ -259,17 +294,19 @@ export function createResumableUploadRuntime<Capability>(
|
||||
dependencies.crossContextCancellation?.publish(uploadKey);
|
||||
}
|
||||
const combined = combineAbortSignals(input.signal, lifetime.signal);
|
||||
const operation = dependencies.mutationLock.run(
|
||||
uploadKey,
|
||||
combined.signal,
|
||||
async () =>
|
||||
await executeAbort(dependencies, uploadKey, combined.signal),
|
||||
);
|
||||
// TR-RR-06. An abort is admitted physical work like an upload, so it is
|
||||
// tracked from admission and `dispose()` cannot step over it.
|
||||
const tracked = Promise.resolve(operation).catch(() => undefined);
|
||||
activeOperations.add(tracked);
|
||||
void tracked.finally(() => activeOperations.delete(tracked));
|
||||
try {
|
||||
return await dependencies.mutationLock.run(
|
||||
uploadKey,
|
||||
combined.signal,
|
||||
async () =>
|
||||
await executeAbort(
|
||||
dependencies,
|
||||
uploadKey,
|
||||
combined.signal,
|
||||
),
|
||||
);
|
||||
return await operation;
|
||||
} catch (error) {
|
||||
return mapLockFailure(error, "UPLOAD_ABORT");
|
||||
} finally {
|
||||
@@ -278,14 +315,65 @@ export function createResumableUploadRuntime<Capability>(
|
||||
},
|
||||
|
||||
close() {
|
||||
if (closed) return;
|
||||
// BT-UP-06. Admission closes synchronously; the drain runs behind the
|
||||
// same single-flight promise dispose() returns.
|
||||
void startDrain();
|
||||
},
|
||||
|
||||
dispose(): Promise<BrowserDataResult<void>> {
|
||||
return startDrain();
|
||||
},
|
||||
|
||||
lifecycle: () => lifecycle,
|
||||
});
|
||||
|
||||
function startDrain(): Promise<BrowserDataResult<void>> {
|
||||
drain ??= (async () => {
|
||||
closed = true;
|
||||
lifecycle = "CLOSING";
|
||||
releaseCrossContextCancellation?.();
|
||||
dependencies.crossContextCancellation?.close();
|
||||
// Abort every admitted operation, then wait for their real settlement.
|
||||
lifetime.abort();
|
||||
for (const controllers of localUploads.values()) {
|
||||
for (const controller of controllers) controller.abort();
|
||||
}
|
||||
// TR-RR-06. Bounded. A non-cooperative mutation lock or provider must not
|
||||
// make teardown unbounded, and an unproved drain is reported as such
|
||||
// rather than closed over.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const expired = new Promise<"EXPIRED">((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve("EXPIRED"),
|
||||
dependencies.policy.cleanupDeadlineMs,
|
||||
);
|
||||
});
|
||||
// TR-04. Quiescence means both registries: the bounded wrappers and the
|
||||
// raw provider work they may have outlived. A settling wrapper can still
|
||||
// register more physical work, so the drain repeats until both are empty
|
||||
// or the cleanup deadline expires.
|
||||
const quiescent = (async () => {
|
||||
while (activeOperations.size > 0 || physicalTasks.size > 0) {
|
||||
await Promise.allSettled([...activeOperations, ...physicalTasks]);
|
||||
}
|
||||
return "DRAINED" as const;
|
||||
})();
|
||||
const drained = await Promise.race([quiescent, expired]);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
if (drained === "EXPIRED") {
|
||||
// The store stays open: something can still write a checkpoint.
|
||||
return browserDataFailure("UNAVAILABLE", "UPLOAD_ABORT", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
});
|
||||
}
|
||||
// The store closes only after nothing can still write a checkpoint.
|
||||
dependencies.checkpoints.close();
|
||||
},
|
||||
});
|
||||
lifecycle = "CLOSED";
|
||||
return browserDataSuccess(undefined);
|
||||
})();
|
||||
return drain;
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1238,8 +1326,20 @@ async function invokeProviderAttempt<Capability, Value>(
|
||||
}, dependencies.policy.providerAttemptTimeoutMs);
|
||||
});
|
||||
try {
|
||||
// TR-04. The raw promise enters the physical registry the moment the
|
||||
// provider is called and stays there until it truly settles. Racing it
|
||||
// against a deadline let the bounded wrapper settle first and leave the
|
||||
// set empty, so `dispose()` reported a drained runtime while the provider
|
||||
// was still running.
|
||||
const raw = action(controller.signal);
|
||||
const tracked = Promise.resolve(raw).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
dependencies.physicalTasks.add(tracked);
|
||||
void tracked.finally(() => dependencies.physicalTasks.delete(tracked));
|
||||
return await Promise.race([
|
||||
invokeProvider(operation, () => action(controller.signal)),
|
||||
invokeProvider(operation, () => raw),
|
||||
deadline,
|
||||
]);
|
||||
} finally {
|
||||
@@ -1671,6 +1771,7 @@ function snapshotRequest(
|
||||
|
||||
function snapshotDependencies<Capability>(
|
||||
input: ResumableUploadRuntimeDependencies<Capability>,
|
||||
physicalTasks: Set<Promise<unknown>>,
|
||||
): RuntimeDependencies<Capability> {
|
||||
const policy = resolveResumableUploadRuntimePolicy(input.policy);
|
||||
const controlPlane = snapshotControlPlane(input.controlPlane);
|
||||
@@ -1693,6 +1794,7 @@ function snapshotDependencies<Capability>(
|
||||
throw new TypeError("Upload runtime dependency is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
physicalTasks,
|
||||
controlPlane,
|
||||
partExecutor,
|
||||
checkpoints,
|
||||
|
||||
@@ -13,6 +13,12 @@ export type ResumableUploadRuntimePolicy = Readonly<{
|
||||
capabilityRefreshSkewMs: number;
|
||||
maxSessionLifetimeMs: number;
|
||||
providerAttemptTimeoutMs: number;
|
||||
/**
|
||||
* TR-RR-06. The bound `dispose()` applies to its drain. A non-cooperative
|
||||
* mutation lock or provider would otherwise make teardown unbounded, so a
|
||||
* caller could never learn whether the runtime was quiescent.
|
||||
*/
|
||||
cleanupDeadlineMs: number;
|
||||
}>;
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
@@ -49,6 +55,7 @@ const DEFAULT_POLICY: ResumableUploadRuntimePolicy = Object.freeze({
|
||||
capabilityRefreshSkewMs: 5_000,
|
||||
maxSessionLifetimeMs: 24 * 60 * 60_000,
|
||||
providerAttemptTimeoutMs: 30_000,
|
||||
cleanupDeadlineMs: 10_000,
|
||||
});
|
||||
|
||||
export function resolveResumableUploadRuntimePolicy(
|
||||
@@ -95,6 +102,9 @@ export function resolveResumableUploadRuntimePolicy(
|
||||
!positiveSafeInteger(policy.providerAttemptTimeoutMs) ||
|
||||
policy.providerAttemptTimeoutMs >
|
||||
ABSOLUTE_LIMITS.maxProviderAttemptTimeoutMs ||
|
||||
!positiveSafeInteger(policy.cleanupDeadlineMs) ||
|
||||
policy.cleanupDeadlineMs >
|
||||
ABSOLUTE_LIMITS.maxProviderAttemptTimeoutMs * 2 ||
|
||||
Math.ceil(policy.maxFileBytes / policy.partSizeBytes) >
|
||||
policy.maxPartCount
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user