feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user