520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import type { Route } from "@playwright/test";
|
|
|
|
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
|
|
|
const PAGE_ORIGIN = "http://127.0.0.1:4174";
|
|
const API_ORIGIN = "https://upload-api.example.test";
|
|
const OBJECT_ORIGIN = "https://upload-objects.example.test";
|
|
const SESSION_ID = "session_browser_01";
|
|
const RESOURCE_ID = "resource_browser_01";
|
|
const UPLOAD_PROTOCOL = "PRESIGNED_MULTIPART_V1";
|
|
|
|
type JsonRecord = Readonly<Record<string, unknown>>;
|
|
|
|
function corsHeaders(): Record<string, string> {
|
|
return {
|
|
"access-control-allow-credentials": "true",
|
|
"access-control-allow-headers": "content-type",
|
|
"access-control-allow-methods": "POST, OPTIONS",
|
|
"access-control-allow-origin": PAGE_ORIGIN,
|
|
};
|
|
}
|
|
|
|
async function fulfillJson(
|
|
route: Route,
|
|
status: number,
|
|
value: unknown,
|
|
): Promise<void> {
|
|
const body = JSON.stringify(value);
|
|
await route.fulfill({
|
|
status,
|
|
body,
|
|
headers: {
|
|
...corsHeaders(),
|
|
"content-length": String(Buffer.byteLength(body)),
|
|
"content-type": "application/json; charset=utf-8",
|
|
},
|
|
});
|
|
}
|
|
|
|
test("uploads three presigned parts through native IndexedDB, Web Locks and fetch", async ({
|
|
page,
|
|
}) => {
|
|
let session: JsonRecord | null = null;
|
|
const acceptedParts = new Map<number, JsonRecord>();
|
|
const issuedParts = new Map<number, JsonRecord>();
|
|
const controlRequests: Readonly<{
|
|
path: string;
|
|
body: JsonRecord;
|
|
}>[] = [];
|
|
const uploadedBodies: Readonly<{
|
|
partNumber: number;
|
|
bytes: number[];
|
|
checksum: string | undefined;
|
|
}>[] = [];
|
|
let completedParts: readonly JsonRecord[] = [];
|
|
|
|
await page.route(`${API_ORIGIN}/**`, async (route) => {
|
|
if (route.request().method() === "OPTIONS") {
|
|
await route.fulfill({ status: 204, headers: corsHeaders() });
|
|
return;
|
|
}
|
|
|
|
const path = new URL(route.request().url()).pathname;
|
|
const body = route.request().postDataJSON() as JsonRecord;
|
|
controlRequests.push({ path, body });
|
|
|
|
if (path === "/uploads/create") {
|
|
const fingerprint = body.fingerprint as JsonRecord;
|
|
session = {
|
|
protocol: UPLOAD_PROTOCOL,
|
|
sessionId: SESSION_ID,
|
|
requestBindingSha256: body.requestBindingSha256,
|
|
fingerprint,
|
|
partSizeBytes: body.requestedPartSizeBytes,
|
|
partCount: fingerprint.partCount,
|
|
maxConcurrency: 1,
|
|
expiresAtEpochMs: Date.now() + 5 * 60_000,
|
|
};
|
|
await fulfillJson(route, 201, session);
|
|
return;
|
|
}
|
|
|
|
if (path === "/uploads/status") {
|
|
if (!session) {
|
|
await fulfillJson(route, 404, { state: "NOT_FOUND" });
|
|
return;
|
|
}
|
|
await fulfillJson(route, 200, {
|
|
state: "ACTIVE",
|
|
session,
|
|
acceptedParts: [...acceptedParts.values()].sort(
|
|
(left, right) =>
|
|
Number(left.partNumber) - Number(right.partNumber),
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/uploads/complete") {
|
|
completedParts = body.orderedParts as readonly JsonRecord[];
|
|
await fulfillJson(route, 200, {
|
|
state: "QUARANTINED",
|
|
protocol: UPLOAD_PROTOCOL,
|
|
sessionId: body.sessionId,
|
|
requestBindingSha256: body.requestBindingSha256,
|
|
fingerprint: body.fingerprint,
|
|
resourceId: RESOURCE_ID,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/uploads/abort") {
|
|
await fulfillJson(route, 200, { state: "ABORTED" });
|
|
return;
|
|
}
|
|
|
|
if (path === "/uploads/part-capability") {
|
|
const binding = body.binding as JsonRecord;
|
|
const partNumber = Number(binding.partNumber);
|
|
const descriptor = {
|
|
partNumber,
|
|
offset: binding.offset,
|
|
byteLength: body.byteLength,
|
|
checksumSha256: body.expectedSha256,
|
|
};
|
|
issuedParts.set(partNumber, descriptor);
|
|
const objectPath =
|
|
`/uploads/${SESSION_ID}/parts/${String(partNumber)}`;
|
|
await fulfillJson(route, 200, {
|
|
capabilityReceipt: `browser-part-capability-${String(partNumber)}`,
|
|
method: "PUT",
|
|
binding,
|
|
href:
|
|
`${OBJECT_ORIGIN}${objectPath}?sig=opaque-${String(partNumber)}`,
|
|
origin: OBJECT_ORIGIN,
|
|
path: objectPath,
|
|
allowedQueryParameters: ["sig"],
|
|
requestHeaders: [
|
|
{
|
|
name: "content-type",
|
|
value: body.mediaType,
|
|
},
|
|
{
|
|
name: "x-checksum-sha256",
|
|
value: body.expectedSha256,
|
|
},
|
|
],
|
|
requiredResponseHeaders: [
|
|
{
|
|
name: "x-policy-version",
|
|
value: "v1",
|
|
},
|
|
],
|
|
digestRequestHeader: "x-checksum-sha256",
|
|
digestResponseHeader: null,
|
|
receiptResponseHeader: "etag",
|
|
expectedResponseByteLength: 0,
|
|
expectedStatus: 204,
|
|
mediaType: body.mediaType,
|
|
byteLength: body.byteLength,
|
|
maxBytes: body.byteLength,
|
|
expectedSha256: body.expectedSha256,
|
|
expiresAtEpochMs:
|
|
Number(session?.expiresAtEpochMs) - 10_000,
|
|
singleUse: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({ status: 404, headers: corsHeaders() });
|
|
});
|
|
|
|
await page.route(`${OBJECT_ORIGIN}/**`, async (route) => {
|
|
if (route.request().method() === "OPTIONS") {
|
|
await route.fulfill({
|
|
status: 204,
|
|
headers: {
|
|
"access-control-allow-headers":
|
|
"content-type, x-checksum-sha256",
|
|
"access-control-allow-methods": "PUT, OPTIONS",
|
|
"access-control-allow-origin": "*",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const match = new URL(route.request().url()).pathname.match(
|
|
/\/parts\/([1-9][0-9]*)$/u,
|
|
);
|
|
const partNumber = Number(match?.[1]);
|
|
const body = route.request().postDataBuffer();
|
|
if (!body || !Number.isSafeInteger(partNumber)) {
|
|
await route.fulfill({ status: 400 });
|
|
return;
|
|
}
|
|
const checksum = route.request().headers()["x-checksum-sha256"];
|
|
const actualChecksum = createHash("sha256")
|
|
.update(body)
|
|
.digest("hex");
|
|
const descriptor = issuedParts.get(partNumber);
|
|
if (
|
|
!descriptor ||
|
|
checksum !== actualChecksum ||
|
|
descriptor.checksumSha256 !== actualChecksum ||
|
|
descriptor.byteLength !== body.byteLength
|
|
) {
|
|
await route.fulfill({ status: 422 });
|
|
return;
|
|
}
|
|
const receiptToken = `etag-part-${String(partNumber)}`;
|
|
acceptedParts.set(partNumber, {
|
|
...descriptor,
|
|
receiptToken,
|
|
});
|
|
uploadedBodies.push({
|
|
partNumber,
|
|
bytes: [...body],
|
|
checksum,
|
|
});
|
|
await route.fulfill({
|
|
status: 204,
|
|
headers: {
|
|
"access-control-allow-origin": "*",
|
|
"access-control-expose-headers": "etag, x-policy-version",
|
|
etag: `"${receiptToken}"`,
|
|
"x-policy-version": "v1",
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.goto("/");
|
|
const result = await page.evaluate(
|
|
async ({ apiOrigin, objectOrigin }) => {
|
|
const vaultModulePath =
|
|
"/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
|
|
const providerModulePath =
|
|
"/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts";
|
|
const executorModulePath =
|
|
"/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts";
|
|
const fetchTransportModulePath =
|
|
"/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts";
|
|
const controlPlaneModulePath =
|
|
"/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts";
|
|
const checkpointModulePath =
|
|
"/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts";
|
|
const partExecutorModulePath =
|
|
"/src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts";
|
|
const uploadRuntimeModulePath =
|
|
"/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
|
|
const cancellationModulePath =
|
|
"/src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
|
const lockModulePath =
|
|
"/src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
|
const [
|
|
{
|
|
createPresignedCapabilityVault,
|
|
createSingleUsePresignedReplayGuard,
|
|
},
|
|
{ createPresignedCapabilityHttpProvider },
|
|
{ createPresignedTransferExecutor },
|
|
{ createResumableUploadFetchJsonTransport },
|
|
{ createResumableUploadHttpControlPlane },
|
|
{ createIndexedDbResumableUploadCheckpointRuntime },
|
|
{ createPresignedUploadPartExecutor },
|
|
{ createResumableUploadRuntime },
|
|
{ createBrowserUploadCancellationChannel },
|
|
{ createResumableUploadWebLock },
|
|
] = await Promise.all([
|
|
import(/* @vite-ignore */ vaultModulePath),
|
|
import(/* @vite-ignore */ providerModulePath),
|
|
import(/* @vite-ignore */ executorModulePath),
|
|
import(/* @vite-ignore */ fetchTransportModulePath),
|
|
import(/* @vite-ignore */ controlPlaneModulePath),
|
|
import(/* @vite-ignore */ checkpointModulePath),
|
|
import(/* @vite-ignore */ partExecutorModulePath),
|
|
import(/* @vite-ignore */ uploadRuntimeModulePath),
|
|
import(/* @vite-ignore */ cancellationModulePath),
|
|
import(/* @vite-ignore */ lockModulePath),
|
|
]);
|
|
|
|
const suffix = crypto.randomUUID().replaceAll("-", "");
|
|
const uploadKey = `upload_browser_${suffix}`;
|
|
const checkpointRuntime =
|
|
createIndexedDbResumableUploadCheckpointRuntime({
|
|
scope: {
|
|
authorityToken: `authority_${suffix}`,
|
|
namespaceToken: `namespace_${suffix}`,
|
|
partitionToken: `partition_${suffix}`,
|
|
},
|
|
blockedTimeoutMs: 2_000,
|
|
});
|
|
const vault = createPresignedCapabilityVault({
|
|
maxActiveCapabilities: 8,
|
|
});
|
|
const capabilityProvider =
|
|
createPresignedCapabilityHttpProvider({
|
|
endpoint: `${apiOrigin}/uploads/part-capability`,
|
|
vault,
|
|
allowedDataOrigins: [objectOrigin],
|
|
allowedDataPathPrefixes: ["/uploads/"],
|
|
allowedQueryParameters: ["sig"],
|
|
allowedRequestHeaders: [
|
|
"content-type",
|
|
"x-checksum-sha256",
|
|
],
|
|
allowedResponseHeaders: ["etag", "x-policy-version"],
|
|
hardMaxTransferBytes: 8,
|
|
hardMaxUploadResponseBytes: 1_024,
|
|
maxCapabilityTtlMs: 10 * 60_000,
|
|
minimumRemainingLifetimeMs: 30_000,
|
|
timeoutMs: 5_000,
|
|
controlPlaneCredentials: "include",
|
|
});
|
|
const transferExecutor = createPresignedTransferExecutor({
|
|
vault,
|
|
replayGuard: createSingleUsePresignedReplayGuard(),
|
|
hardMaxTransferBytes: 8,
|
|
hardMaxChunkBytes: 2,
|
|
hardMaxUploadResponseBytes: 1_024,
|
|
minimumRemainingLifetimeMs: 30_000,
|
|
timeoutMs: 5_000,
|
|
});
|
|
const transport = createResumableUploadFetchJsonTransport({
|
|
endpoints: {
|
|
CREATE_SESSION: `${apiOrigin}/uploads/create`,
|
|
GET_STATUS: `${apiOrigin}/uploads/status`,
|
|
COMPLETE: `${apiOrigin}/uploads/complete`,
|
|
ABORT: `${apiOrigin}/uploads/abort`,
|
|
},
|
|
allowedOrigins: [apiOrigin],
|
|
credentials: "include",
|
|
timeoutMs: 5_000,
|
|
maxRequestBytes: 64 * 1024,
|
|
maxResponseBytes: 64 * 1024,
|
|
maxRetryAfterMs: 1_000,
|
|
});
|
|
const controlPlane = createResumableUploadHttpControlPlane({
|
|
transport,
|
|
partCapabilities: capabilityProvider,
|
|
});
|
|
const cancellationChannelName =
|
|
`browser-upload-cancel-${suffix}`;
|
|
const crossContextCancellation =
|
|
createBrowserUploadCancellationChannel({
|
|
channelName: cancellationChannelName,
|
|
});
|
|
const cancellationPeer =
|
|
createBrowserUploadCancellationChannel({
|
|
channelName: cancellationChannelName,
|
|
});
|
|
const runtime = createResumableUploadRuntime({
|
|
controlPlane,
|
|
partExecutor: createPresignedUploadPartExecutor(
|
|
transferExecutor.uploadParts,
|
|
),
|
|
checkpoints: checkpointRuntime.store,
|
|
mutationLock: createResumableUploadWebLock(
|
|
navigator.locks,
|
|
"browser-resumable-upload",
|
|
),
|
|
...(crossContextCancellation
|
|
? { crossContextCancellation }
|
|
: {}),
|
|
crypto,
|
|
policy: {
|
|
partSizeBytes: 2,
|
|
maxFileBytes: 6,
|
|
maxPartCount: 3,
|
|
maxConcurrency: 2,
|
|
maxInFlightBytes: 16,
|
|
partBufferCopyFactor: 4,
|
|
maxSourceChunkBytes: 2,
|
|
maxRetries: 1,
|
|
retryBaseDelayMs: 1,
|
|
retryMaxDelayMs: 5,
|
|
maxRetryAfterMs: 1_000,
|
|
capabilityRefreshSkewMs: 1_000,
|
|
maxSessionLifetimeMs: 10 * 60_000,
|
|
providerAttemptTimeoutMs: 10_000,
|
|
},
|
|
});
|
|
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
|
const progress: string[] = [];
|
|
let upload: unknown;
|
|
let checkpoint: unknown;
|
|
let deletion: unknown;
|
|
let cancellationDelivered = false;
|
|
try {
|
|
if (crossContextCancellation && cancellationPeer) {
|
|
cancellationDelivered = await new Promise<boolean>(
|
|
(resolve) => {
|
|
const timer = setTimeout(() => resolve(false), 2_000);
|
|
const release = cancellationPeer.subscribe((key: string) => {
|
|
if (key !== `upload_probe_${suffix}`) return;
|
|
clearTimeout(timer);
|
|
release();
|
|
resolve(true);
|
|
});
|
|
if (
|
|
!crossContextCancellation.publish(
|
|
`upload_probe_${suffix}`,
|
|
)
|
|
) {
|
|
clearTimeout(timer);
|
|
release();
|
|
resolve(false);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
upload = await runtime.upload({
|
|
uploadKey,
|
|
purpose: "browser_attachment",
|
|
mediaType: "application/octet-stream",
|
|
source: {
|
|
kind: "RANGE_READER",
|
|
reader: {
|
|
byteLength: bytes.byteLength,
|
|
async readRange(input: {
|
|
offset: number;
|
|
length: number;
|
|
signal: AbortSignal;
|
|
}) {
|
|
return input.signal.aborted
|
|
? {
|
|
ok: false as const,
|
|
error: {
|
|
code: "ABORTED" as const,
|
|
operation: "FILE_READ" as const,
|
|
retryable: false,
|
|
recovery: "NONE" as const,
|
|
},
|
|
}
|
|
: {
|
|
ok: true as const,
|
|
value: bytes.slice(
|
|
input.offset,
|
|
input.offset + input.length,
|
|
),
|
|
};
|
|
},
|
|
},
|
|
},
|
|
signal: new AbortController().signal,
|
|
onProgress(value: { phase: string }) {
|
|
progress.push(value.phase);
|
|
},
|
|
});
|
|
checkpoint = await checkpointRuntime.store.read(uploadKey);
|
|
} finally {
|
|
runtime.close();
|
|
cancellationPeer?.close();
|
|
deletion =
|
|
await checkpointRuntime.admin.deletePartition();
|
|
vault.dispose();
|
|
}
|
|
return {
|
|
upload,
|
|
checkpoint,
|
|
deletion,
|
|
progress,
|
|
webLocksAvailable: Boolean(navigator.locks),
|
|
broadcastCancellationAvailable:
|
|
Boolean(crossContextCancellation),
|
|
cancellationDelivered,
|
|
};
|
|
},
|
|
{ apiOrigin: API_ORIGIN, objectOrigin: OBJECT_ORIGIN },
|
|
);
|
|
|
|
expect(result.webLocksAvailable).toBe(true);
|
|
expect(result.broadcastCancellationAvailable).toBe(true);
|
|
expect(result.cancellationDelivered).toBe(true);
|
|
expect(result.upload).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
state: "QUARANTINED",
|
|
resourceId: RESOURCE_ID,
|
|
byteLength: 5,
|
|
replayed: false,
|
|
},
|
|
});
|
|
expect(result.checkpoint).toEqual({ ok: true, value: null });
|
|
expect(result.deletion).toEqual({
|
|
ok: true,
|
|
value: { state: "DELETED" },
|
|
});
|
|
expect(
|
|
uploadedBodies
|
|
.slice()
|
|
.sort((left, right) => left.partNumber - right.partNumber)
|
|
.map(({ partNumber, bytes }) => ({ partNumber, bytes })),
|
|
).toEqual([
|
|
{ partNumber: 1, bytes: [1, 2] },
|
|
{ partNumber: 2, bytes: [3, 4] },
|
|
{ partNumber: 3, bytes: [5] },
|
|
]);
|
|
expect(completedParts.map((part) => part.partNumber)).toEqual([
|
|
1, 2, 3,
|
|
]);
|
|
expect(completedParts.map((part) => part.receiptToken)).toEqual([
|
|
"etag-part-1",
|
|
"etag-part-2",
|
|
"etag-part-3",
|
|
]);
|
|
expect(controlRequests.map((request) => request.path)).toEqual([
|
|
"/uploads/create",
|
|
"/uploads/status",
|
|
"/uploads/part-capability",
|
|
"/uploads/part-capability",
|
|
"/uploads/part-capability",
|
|
"/uploads/status",
|
|
"/uploads/complete",
|
|
]);
|
|
expect(JSON.stringify(controlRequests)).not.toContain("?sig=");
|
|
expect(JSON.stringify(controlRequests)).not.toContain(OBJECT_ORIGIN);
|
|
});
|