feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("selects, inspects, streams, and previews a real browser File", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(async () => {
|
||||
const modulePath =
|
||||
"/src/adapters/browser-files/index.ts";
|
||||
const {
|
||||
createBrowserFileRuntime,
|
||||
browserFilePolicyReference,
|
||||
} = await import(/* @vite-ignore */ modulePath);
|
||||
const input = document.createElement("input");
|
||||
input.id = "capability-file-input";
|
||||
input.type = "file";
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = "PNG file";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Choose test file";
|
||||
document.body.append(label, input, button);
|
||||
|
||||
const filePolicy = browserFilePolicyReference(
|
||||
"avatar",
|
||||
"select-inspect-preview-png",
|
||||
);
|
||||
const runtime = createBrowserFileRuntime({
|
||||
input,
|
||||
policies: [
|
||||
{
|
||||
reference: filePolicy,
|
||||
selection: {
|
||||
policyId: "avatar-v1",
|
||||
purpose: "avatar",
|
||||
classification: "PERSONAL",
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxFileBytes: 1024,
|
||||
maxTotalBytes: 1024,
|
||||
allowEmpty: false,
|
||||
accept: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
extensions: [".png"],
|
||||
},
|
||||
],
|
||||
},
|
||||
inspection: {
|
||||
policyId: "png-signature-v1",
|
||||
maxInspectionBytes: 16,
|
||||
acceptedSignatures: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
extensions: [".png"],
|
||||
patterns: [
|
||||
{
|
||||
offset: 0,
|
||||
bytes: [137, 80, 78, 71, 13, 10, 26, 10],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
preview: {
|
||||
allowedMediaTypes: ["image/png"],
|
||||
maxPreviewBytes: 1024,
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: {
|
||||
hardMaxPreviewBytes: 1024,
|
||||
hardMaxObjectUrlBytes: 1024,
|
||||
hardMaxTransferBytes: 4096,
|
||||
},
|
||||
download: {
|
||||
host: Object.freeze({ handoff() {} }),
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "POLICY_REJECTED" as const,
|
||||
operation: "DOWNLOAD" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__browserFileResult?: Promise<unknown>;
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
state.__browserFileResult = (async () => {
|
||||
const selected = await runtime.baselinePicker.select({
|
||||
policy: filePolicy,
|
||||
});
|
||||
if (
|
||||
!selected.ok ||
|
||||
selected.value.kind !== "SELECTED" ||
|
||||
selected.value.files.length !== 1
|
||||
) {
|
||||
runtime.dispose();
|
||||
return selected;
|
||||
}
|
||||
const candidate = selected.value.files[0]!;
|
||||
const signal = new AbortController().signal;
|
||||
const inspected = await runtime.content.inspect({
|
||||
ref: candidate.ref,
|
||||
policy: filePolicy,
|
||||
signal,
|
||||
});
|
||||
const range = await runtime.content.readRange({
|
||||
ref: candidate.ref,
|
||||
offset: 1,
|
||||
length: 4,
|
||||
signal,
|
||||
});
|
||||
const opened = await runtime.content.openSource({
|
||||
ref: candidate.ref,
|
||||
signal,
|
||||
});
|
||||
const streamed: number[] = [];
|
||||
if (opened.ok) {
|
||||
for await (const chunk of opened.value.stream(signal)) {
|
||||
if (chunk.ok) streamed.push(...chunk.value);
|
||||
}
|
||||
}
|
||||
const verificationReceipt = inspected.ok
|
||||
? inspected.value.verificationReceipt
|
||||
: null;
|
||||
if (!verificationReceipt) {
|
||||
runtime.dispose();
|
||||
return inspected;
|
||||
}
|
||||
const preview = await runtime.previews.create({
|
||||
ref: candidate.ref,
|
||||
verificationReceipt,
|
||||
policy: filePolicy,
|
||||
maxPreviewBytes: 1024,
|
||||
signal,
|
||||
});
|
||||
let previewBytes: number[] = [];
|
||||
let previewProtocol: string | null = null;
|
||||
if (preview.ok) {
|
||||
previewProtocol = new URL(preview.value.url).protocol;
|
||||
previewBytes = Array.from(
|
||||
new Uint8Array(await (await fetch(preview.value.url)).arrayBuffer()),
|
||||
);
|
||||
preview.value.release();
|
||||
}
|
||||
runtime.baselinePicker.release(candidate.ref);
|
||||
runtime.dispose();
|
||||
return {
|
||||
selected,
|
||||
inspected,
|
||||
range: range.ok ? Array.from(range.value) : range,
|
||||
streamed,
|
||||
preview: preview.ok ? { ok: true } : preview,
|
||||
previewBytes,
|
||||
previewProtocol,
|
||||
};
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "Choose test file" }).click();
|
||||
const chooser = await chooserPromise;
|
||||
const bytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]);
|
||||
await chooser.setFiles({
|
||||
name: "sample.png",
|
||||
mimeType: "image/png",
|
||||
buffer: bytes,
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__browserFileResult?: Promise<unknown>;
|
||||
};
|
||||
return await state.__browserFileResult;
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
selected: {
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "SELECTED",
|
||||
files: [{ sizeBytes: bytes.byteLength, source: "NATIVE_INPUT" }],
|
||||
},
|
||||
},
|
||||
inspected: {
|
||||
ok: true,
|
||||
value: {
|
||||
byteLength: bytes.byteLength,
|
||||
detectedMediaType: "image/png",
|
||||
signature: "MATCHED",
|
||||
},
|
||||
},
|
||||
range: [80, 78, 71, 13],
|
||||
streamed: Array.from(bytes),
|
||||
preview: { ok: true },
|
||||
previewBytes: Array.from(bytes),
|
||||
previewProtocol: "blob:",
|
||||
});
|
||||
});
|
||||
|
||||
test("hands a bounded generated Blob to the real browser download path", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(async () => {
|
||||
const modulePath = "/src/adapters/browser-files/index.ts";
|
||||
const {
|
||||
createBrowserFileRuntime,
|
||||
createAnchorDownloadHost,
|
||||
browserFilePolicyReference,
|
||||
} = await import(/* @vite-ignore */ modulePath);
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.id = "unused-download-input";
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = "Unused file control";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Download generated file";
|
||||
document.body.append(label, input, button);
|
||||
const downloadPolicy = browserFilePolicyReference(
|
||||
"generated-export",
|
||||
"bounded-object-url-bin",
|
||||
);
|
||||
const runtime = createBrowserFileRuntime({
|
||||
input,
|
||||
policies: [
|
||||
{
|
||||
reference: downloadPolicy,
|
||||
download: {
|
||||
strategy: "BOUNDED_OBJECT_URL",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 1024,
|
||||
maxBufferedBytes: 1024,
|
||||
integrity: "OPTIONAL",
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: {
|
||||
hardMaxPreviewBytes: 1024,
|
||||
hardMaxObjectUrlBytes: 1024,
|
||||
hardMaxTransferBytes: 4096,
|
||||
},
|
||||
download: {
|
||||
host: createAnchorDownloadHost(document),
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "POLICY_REJECTED" as const,
|
||||
operation: "DOWNLOAD" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__browserDownloadResult?: Promise<unknown>;
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
state.__browserDownloadResult = runtime.downloads
|
||||
.deliver({
|
||||
policy: downloadPolicy,
|
||||
source: {
|
||||
kind: "GENERATED",
|
||||
bytes: {
|
||||
byteLength: 5,
|
||||
async *stream() {
|
||||
yield {
|
||||
ok: true as const,
|
||||
value: new Uint8Array([1, 2]),
|
||||
};
|
||||
yield {
|
||||
ok: true as const,
|
||||
value: new Uint8Array([3, 4, 5]),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestedFileName: "report",
|
||||
maxTransferBytes: 1024,
|
||||
maxBufferedBytes: 1024,
|
||||
signal: new AbortController().signal,
|
||||
onProgress() {},
|
||||
})
|
||||
.finally(() => runtime.dispose());
|
||||
});
|
||||
});
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "Download generated file" })
|
||||
.click();
|
||||
const download = await downloadPromise;
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const result = await page.evaluate(async () => {
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__browserDownloadResult?: Promise<unknown>;
|
||||
};
|
||||
return await state.__browserDownloadResult;
|
||||
});
|
||||
|
||||
expect(download.suggestedFilename()).toBe("report.bin");
|
||||
expect(Buffer.concat(chunks)).toEqual(Buffer.from([1, 2, 3, 4, 5]));
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "BROWSER_HANDOFF",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type Page,
|
||||
} from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
const TOPIC = "reference-resources";
|
||||
const TOPIC_VERSION = 1;
|
||||
const STATE_KEY = "__crossContextInvalidationCapability";
|
||||
const STORAGE_PULSE_KEY =
|
||||
"ca-frontend:cache-invalidation:v1:pulse";
|
||||
|
||||
type BrowserTransportMode = "BROADCAST" | "STORAGE";
|
||||
|
||||
type BrowserDeliverySnapshot = Readonly<{
|
||||
ordering: string;
|
||||
sequence: number;
|
||||
topic: string;
|
||||
transport: string;
|
||||
}>;
|
||||
|
||||
type BrowserRuntimeSnapshot = Readonly<{
|
||||
status: string;
|
||||
deliveries: readonly BrowserDeliverySnapshot[];
|
||||
pulseRetained: boolean;
|
||||
}>;
|
||||
|
||||
async function installRuntime(
|
||||
page: Page,
|
||||
mode: BrowserTransportMode,
|
||||
cacheEpoch: string,
|
||||
): Promise<string> {
|
||||
return await page.evaluate(
|
||||
async ({
|
||||
currentCacheEpoch,
|
||||
currentMode,
|
||||
stateKey,
|
||||
topic,
|
||||
topicVersion,
|
||||
}) => {
|
||||
const modulePath =
|
||||
"/src/adapters/cross-context-invalidation/index.ts";
|
||||
const { createBrowserCrossContextInvalidationFromHost } =
|
||||
(await import(
|
||||
/* @vite-ignore */ modulePath
|
||||
)) as typeof import("../../src/adapters/cross-context-invalidation/index.ts");
|
||||
|
||||
const host: Record<string, unknown> =
|
||||
currentMode === "BROADCAST"
|
||||
? (globalThis as unknown as Record<string, unknown>)
|
||||
: {
|
||||
crypto: globalThis.crypto,
|
||||
localStorage: globalThis.localStorage,
|
||||
addEventListener:
|
||||
globalThis.addEventListener.bind(globalThis),
|
||||
removeEventListener:
|
||||
globalThis.removeEventListener.bind(globalThis),
|
||||
};
|
||||
const runtime = createBrowserCrossContextInvalidationFromHost({
|
||||
host,
|
||||
cacheEpoch: currentCacheEpoch,
|
||||
topics: [{ topic, topicVersion }],
|
||||
});
|
||||
if (!runtime) {
|
||||
throw new Error(
|
||||
"Cross-context invalidation runtime is unavailable.",
|
||||
);
|
||||
}
|
||||
const deliveries: BrowserDeliverySnapshot[] = [];
|
||||
runtime.subscribe((delivery) => {
|
||||
deliveries.push({
|
||||
ordering: delivery.ordering,
|
||||
sequence: delivery.event.sequence,
|
||||
topic: delivery.event.topic,
|
||||
transport: delivery.transport,
|
||||
});
|
||||
});
|
||||
Reflect.set(globalThis, stateKey, { deliveries, runtime });
|
||||
return runtime.getStatus();
|
||||
},
|
||||
{
|
||||
currentCacheEpoch: cacheEpoch,
|
||||
currentMode: mode,
|
||||
stateKey: STATE_KEY,
|
||||
topic: TOPIC,
|
||||
topicVersion: TOPIC_VERSION,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function publish(page: Page) {
|
||||
return await page.evaluate(
|
||||
({ stateKey, topic, topicVersion }) => {
|
||||
const state = Reflect.get(globalThis, stateKey) as
|
||||
| {
|
||||
runtime: {
|
||||
publish(input: {
|
||||
topic: string;
|
||||
topicVersion: number;
|
||||
}): unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
if (!state) throw new Error("Capability runtime is not installed.");
|
||||
return state.runtime.publish({ topic, topicVersion });
|
||||
},
|
||||
{
|
||||
stateKey: STATE_KEY,
|
||||
topic: TOPIC,
|
||||
topicVersion: TOPIC_VERSION,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function snapshot(page: Page): Promise<BrowserRuntimeSnapshot> {
|
||||
return await page.evaluate(
|
||||
({ pulseKey, stateKey }) => {
|
||||
const state = Reflect.get(globalThis, stateKey) as
|
||||
| {
|
||||
deliveries: BrowserDeliverySnapshot[];
|
||||
runtime: { getStatus(): string };
|
||||
}
|
||||
| undefined;
|
||||
if (!state) throw new Error("Capability runtime is not installed.");
|
||||
return {
|
||||
status: state.runtime.getStatus(),
|
||||
deliveries: structuredClone(state.deliveries),
|
||||
pulseRetained: localStorage.getItem(pulseKey) !== null,
|
||||
};
|
||||
},
|
||||
{ pulseKey: STORAGE_PULSE_KEY, stateKey: STATE_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
async function closeRuntime(page: Page): Promise<string | null> {
|
||||
return await page.evaluate((stateKey) => {
|
||||
const state = Reflect.get(globalThis, stateKey) as
|
||||
| { runtime: { close(): void; getStatus(): string } }
|
||||
| undefined;
|
||||
if (!state) return null;
|
||||
state.runtime.close();
|
||||
return state.runtime.getStatus();
|
||||
}, STATE_KEY);
|
||||
}
|
||||
|
||||
async function disposeRuntime(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ pulseKey, stateKey }) => {
|
||||
const state = Reflect.get(globalThis, stateKey) as
|
||||
| { runtime: { close(): void } }
|
||||
| undefined;
|
||||
state?.runtime.close();
|
||||
localStorage.removeItem(pulseKey);
|
||||
Reflect.deleteProperty(globalThis, stateKey);
|
||||
},
|
||||
{ pulseKey: STORAGE_PULSE_KEY, stateKey: STATE_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
test("delivers invalidation through native BroadcastChannel and cleans the receiver", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondPage = await page.context().newPage();
|
||||
const cacheEpoch = `browser.primary.${Date.now()}`;
|
||||
try {
|
||||
await Promise.all([page.goto("/"), secondPage.goto("/")]);
|
||||
await page.evaluate(
|
||||
(pulseKey) => localStorage.removeItem(pulseKey),
|
||||
STORAGE_PULSE_KEY,
|
||||
);
|
||||
const statuses = await Promise.all([
|
||||
installRuntime(page, "BROADCAST", cacheEpoch),
|
||||
installRuntime(secondPage, "BROADCAST", cacheEpoch),
|
||||
]);
|
||||
expect(statuses).toEqual([
|
||||
"ACTIVE_BROADCAST",
|
||||
"ACTIVE_BROADCAST",
|
||||
]);
|
||||
|
||||
expect(await publish(page)).toEqual({
|
||||
ok: true,
|
||||
transport: "BROADCAST",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await snapshot(secondPage)).deliveries)
|
||||
.toEqual([
|
||||
{
|
||||
ordering: "NEXT",
|
||||
sequence: 1,
|
||||
topic: TOPIC,
|
||||
transport: "BROADCAST",
|
||||
},
|
||||
]);
|
||||
expect((await snapshot(page)).deliveries).toEqual([]);
|
||||
|
||||
expect(await closeRuntime(secondPage)).toBe("CLOSED");
|
||||
expect(await publish(page)).toEqual({
|
||||
ok: true,
|
||||
transport: "BROADCAST",
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
expect(await snapshot(secondPage)).toEqual({
|
||||
status: "CLOSED",
|
||||
deliveries: [
|
||||
{
|
||||
ordering: "NEXT",
|
||||
sequence: 1,
|
||||
topic: TOPIC,
|
||||
transport: "BROADCAST",
|
||||
},
|
||||
],
|
||||
pulseRetained: false,
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
disposeRuntime(page),
|
||||
disposeRuntime(secondPage),
|
||||
]);
|
||||
await secondPage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to native localStorage events and removes the pulse during cleanup", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondPage = await page.context().newPage();
|
||||
const cacheEpoch = `browser.fallback.${Date.now()}`;
|
||||
try {
|
||||
await Promise.all([page.goto("/"), secondPage.goto("/")]);
|
||||
await page.evaluate(
|
||||
(pulseKey) => localStorage.removeItem(pulseKey),
|
||||
STORAGE_PULSE_KEY,
|
||||
);
|
||||
const statuses = await Promise.all([
|
||||
installRuntime(page, "STORAGE", cacheEpoch),
|
||||
installRuntime(secondPage, "STORAGE", cacheEpoch),
|
||||
]);
|
||||
expect(statuses).toEqual([
|
||||
"ACTIVE_STORAGE_FALLBACK",
|
||||
"ACTIVE_STORAGE_FALLBACK",
|
||||
]);
|
||||
|
||||
expect(await publish(page)).toEqual({
|
||||
ok: true,
|
||||
transport: "STORAGE",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await snapshot(secondPage)).deliveries)
|
||||
.toEqual([
|
||||
{
|
||||
ordering: "NEXT",
|
||||
sequence: 1,
|
||||
topic: TOPIC,
|
||||
transport: "STORAGE",
|
||||
},
|
||||
]);
|
||||
expect(await snapshot(page)).toMatchObject({
|
||||
deliveries: [],
|
||||
pulseRetained: false,
|
||||
});
|
||||
expect(await snapshot(secondPage)).toMatchObject({
|
||||
pulseRetained: false,
|
||||
});
|
||||
|
||||
expect(await closeRuntime(secondPage)).toBe("CLOSED");
|
||||
expect(await publish(page)).toEqual({
|
||||
ok: true,
|
||||
transport: "STORAGE",
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
expect(await snapshot(secondPage)).toEqual({
|
||||
status: "CLOSED",
|
||||
deliveries: [
|
||||
{
|
||||
ordering: "NEXT",
|
||||
sequence: 1,
|
||||
topic: TOPIC,
|
||||
transport: "STORAGE",
|
||||
},
|
||||
],
|
||||
pulseRetained: false,
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
disposeRuntime(page),
|
||||
disposeRuntime(secondPage),
|
||||
]);
|
||||
await secondPage.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
test("fetches and decodes an allowlisted public immutable CDN image", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cdnRequests: string[] = [];
|
||||
await page.route("https://images.example.test/**", async (route) => {
|
||||
cdnRequests.push(route.request().url());
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: PNG_1X1,
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control":
|
||||
"public, max-age=31536000, s-maxage=31536000, immutable",
|
||||
"content-length": String(PNG_1X1.byteLength),
|
||||
"content-type": "image/png",
|
||||
vary: "Accept-Encoding",
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const modulePath =
|
||||
"/src/adapters/browser-transfer/image-cdn/index.ts";
|
||||
const {
|
||||
ImageCdnPolicyRegistry,
|
||||
createBrowserImageProbe,
|
||||
createImageCdnRuntime,
|
||||
imageCdnPresetReference,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ modulePath
|
||||
)) as typeof import("../../src/adapters/browser-transfer/image-cdn/index.ts");
|
||||
|
||||
const preset = imageCdnPresetReference(
|
||||
"browser-probe",
|
||||
"verify-public-cdn-image",
|
||||
);
|
||||
const policies = new ImageCdnPolicyRegistry({
|
||||
applicationOrigin: "https://app.example.test",
|
||||
origins: [
|
||||
{
|
||||
originKey: "browser-images",
|
||||
origin: "https://images.example.test",
|
||||
assetPathPrefix: "/v1/assets/",
|
||||
minimumPublicMaxAgeSeconds: 31_536_000,
|
||||
},
|
||||
],
|
||||
presets: [
|
||||
{
|
||||
reference: preset,
|
||||
bindingId: "browser-probe-v1",
|
||||
width: 1,
|
||||
height: 1,
|
||||
fit: "cover",
|
||||
dprs: [1],
|
||||
responsiveWidths: [1],
|
||||
quality: 90,
|
||||
formats: ["png"],
|
||||
sizes: "1px",
|
||||
loading: "eager",
|
||||
decoding: "async",
|
||||
fetchPriority: "high",
|
||||
referrerPolicy: "no-referrer",
|
||||
probeMode: "PRIMARY_REQUIRED",
|
||||
allowUpscale: false,
|
||||
maxTransformedPixels: 1,
|
||||
maxDecodedBytes: 4,
|
||||
maxEncodedBytes: 128,
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxIntrinsicWidth: 1,
|
||||
maxIntrinsicHeight: 1,
|
||||
maxSourcePixels: 1,
|
||||
maxCssDimension: 1,
|
||||
maxDpr: 1,
|
||||
maxQuality: 90,
|
||||
maxCandidateCount: 1,
|
||||
maxTransformedPixels: 1,
|
||||
maxDecodedBytes: 4,
|
||||
maxEncodedBytes: 128,
|
||||
maxUrlLength: 2_048,
|
||||
maxCapabilityLifetimeMs: 60_000,
|
||||
maxClockSkewMs: 1_000,
|
||||
minCapabilityRemainingMs: 1_000,
|
||||
maxPresetBindingsPerCapability: 1,
|
||||
maxConcurrentCapabilityVerifications: 1,
|
||||
allowedSourceMediaTypes: ["image/png"],
|
||||
formatQualityCeilings: { png: 90 },
|
||||
},
|
||||
capability: {
|
||||
issuer: "browser-image-bff",
|
||||
acceptedKeyIds: ["browser-image-key-v1"],
|
||||
},
|
||||
});
|
||||
const runtime = createImageCdnRuntime({
|
||||
policies,
|
||||
probe: createBrowserImageProbe(),
|
||||
});
|
||||
const accepted = runtime.assets.acceptPublicImmutable({
|
||||
kind: "ALLOWLISTED_PUBLIC",
|
||||
originKey: "browser-images",
|
||||
assetId: "asset_1x1",
|
||||
revision: "revision_1",
|
||||
mediaType: "image/png",
|
||||
contentKind: "RASTER_STATIC",
|
||||
intrinsicWidth: 1,
|
||||
intrinsicHeight: 1,
|
||||
});
|
||||
if (!accepted.ok) {
|
||||
runtime.close();
|
||||
return {
|
||||
accepted,
|
||||
resolved: null,
|
||||
};
|
||||
}
|
||||
const resolved = await runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
runtime.close();
|
||||
return {
|
||||
accepted: { ok: true as const },
|
||||
resolved,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.accepted).toEqual({ ok: true });
|
||||
expect(result.resolved).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
fallbackMediaType: "image/png",
|
||||
srcSet: expect.stringMatching(/ 1w$/u),
|
||||
sources: [],
|
||||
loading: "eager",
|
||||
decoding: "async",
|
||||
fetchPriority: "high",
|
||||
referrerPolicy: "no-referrer",
|
||||
crossOrigin: "anonymous",
|
||||
delivery: {
|
||||
class: "PUBLIC_IMMUTABLE",
|
||||
assetVersion: "revision_1",
|
||||
browserCache: "PUBLIC_IMMUTABLE",
|
||||
sharedCache: "PUBLIC_IMMUTABLE",
|
||||
purge: "REVISION_ROLLOVER",
|
||||
expiresAtEpochMs: null,
|
||||
},
|
||||
decodeBudget: {
|
||||
maximumCandidatePixels: 1,
|
||||
maximumDecodedBytes: 4,
|
||||
maximumEncodedBytes: 128,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result.resolved?.ok) return;
|
||||
|
||||
const imageUrl = new URL(result.resolved.value.src);
|
||||
expect(imageUrl.origin).toBe("https://images.example.test");
|
||||
expect(imageUrl.pathname).toBe(
|
||||
"/v1/assets/asset_1x1/revision_1",
|
||||
);
|
||||
expect(Object.fromEntries(imageUrl.searchParams)).toEqual({
|
||||
dpr: "1",
|
||||
fit: "cover",
|
||||
format: "png",
|
||||
height: "1",
|
||||
preset: "browser-probe-v1",
|
||||
quality: "90",
|
||||
width: "1",
|
||||
});
|
||||
expect(cdnRequests).toEqual([result.resolved.value.src]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("runs put, open, remove and reconcile through a native DedicatedWorker, OPFS and IndexedDB", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtimeModulePath =
|
||||
"/src/adapters/storage/opfs/browser-opfs-runtime.ts";
|
||||
const policyModulePath =
|
||||
"/src/adapters/storage/opfs/opfs-policy.ts";
|
||||
const journalModulePath =
|
||||
"/src/adapters/storage/opfs/indexeddb-opfs-journal.ts";
|
||||
const {
|
||||
createBrowserOpfsRuntime,
|
||||
inspectBrowserOpfsSupport,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ runtimeModulePath
|
||||
)) as typeof import("../../src/adapters/storage/opfs/browser-opfs-runtime.ts");
|
||||
const {
|
||||
DEFAULT_OPFS_RUNTIME_POLICY,
|
||||
resolveOpfsRuntimePolicy,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ policyModulePath
|
||||
)) as typeof import("../../src/adapters/storage/opfs/opfs-policy.ts");
|
||||
const { opfsJournalDatabaseName } = (await import(
|
||||
/* @vite-ignore */ journalModulePath
|
||||
)) as typeof import("../../src/adapters/storage/opfs/indexeddb-opfs-journal.ts");
|
||||
const policy = resolveOpfsRuntimePolicy();
|
||||
const support = inspectBrowserOpfsSupport(policy);
|
||||
if (!support.ok) {
|
||||
return {
|
||||
nativeSupport: false as const,
|
||||
capabilities: support,
|
||||
};
|
||||
}
|
||||
|
||||
const suffix = crypto.randomUUID().replaceAll("-", "");
|
||||
const scope = {
|
||||
namespace: "durable-objects",
|
||||
authorityToken: `authority_${suffix}`,
|
||||
namespaceToken: `namespace_${suffix}`,
|
||||
partitionToken: `partition_${suffix}`,
|
||||
} as const;
|
||||
const storagePolicy = {
|
||||
owner: "browser-capability",
|
||||
namespace: scope.namespace,
|
||||
classification: "PERSONAL",
|
||||
authority: "LOCAL_FIRST",
|
||||
accountScope: "OPAQUE_PARTITION",
|
||||
retention: { kind: "EXPLICIT_DELETE" },
|
||||
softBudgetBytes: 1024 * 1024,
|
||||
hardBudgetBytes: 2 * 1024 * 1024,
|
||||
evictionPriority: "USER_AUTHORED",
|
||||
logoutAction: "EXPORT_THEN_PURGE",
|
||||
accountDeletionAction: "PURGE_PARTITION",
|
||||
pressureAction: "RETAIN",
|
||||
unavailableFallback: "EXPORT_REQUIRED",
|
||||
} as const;
|
||||
const runtime = createBrowserOpfsRuntime({
|
||||
workerUrl: new URL(
|
||||
"/tests/browser-capabilities/opfs-test.worker.ts",
|
||||
location.href,
|
||||
),
|
||||
workerName: `opfs-capability-${suffix}`,
|
||||
scope,
|
||||
storagePolicy,
|
||||
policy,
|
||||
});
|
||||
const bytes = new Uint8Array([3, 1, 4, 1, 5, 9]);
|
||||
let capabilities: unknown;
|
||||
let put: unknown;
|
||||
let opened:
|
||||
| Awaited<ReturnType<typeof runtime.objects.open>>
|
||||
| undefined;
|
||||
let readBytes: number[] = [];
|
||||
let removed: unknown;
|
||||
let afterRemove: unknown;
|
||||
let reconciled: unknown;
|
||||
try {
|
||||
capabilities = await runtime.objects.capabilities();
|
||||
put = await runtime.objects.put({
|
||||
objectId: "object_browser_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: {
|
||||
byteLength: bytes.byteLength,
|
||||
async *stream(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
yield {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "ABORTED" as const,
|
||||
operation: "OBJECT_WRITE" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
yield { ok: true as const, value: Uint8Array.from(bytes) };
|
||||
},
|
||||
},
|
||||
});
|
||||
opened = await runtime.objects.open({
|
||||
objectId: "object_browser_12345678",
|
||||
});
|
||||
if (
|
||||
opened &&
|
||||
typeof opened === "object" &&
|
||||
"ok" in opened &&
|
||||
opened.ok
|
||||
) {
|
||||
for await (const chunk of opened.value.source.stream(
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
if (!chunk.ok) throw new Error(chunk.error.code);
|
||||
readBytes.push(...chunk.value);
|
||||
}
|
||||
}
|
||||
removed = await runtime.objects.remove({
|
||||
objectId: "object_browser_12345678",
|
||||
expectedGeneration: 1,
|
||||
});
|
||||
afterRemove = await runtime.objects.open({
|
||||
objectId: "object_browser_12345678",
|
||||
});
|
||||
reconciled = await runtime.maintenance.reconcile({
|
||||
budgetMs: 5_000,
|
||||
maxTransactions: 100,
|
||||
});
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
|
||||
const deletion = await new Promise<string>((resolve) => {
|
||||
const request = indexedDB.deleteDatabase(
|
||||
opfsJournalDatabaseName(scope.authorityToken),
|
||||
);
|
||||
request.onsuccess = () => resolve("DELETED");
|
||||
request.onerror = () =>
|
||||
resolve(request.error?.name ?? "UNKNOWN_ERROR");
|
||||
request.onblocked = () => resolve("BLOCKED");
|
||||
});
|
||||
let opfsCleanup = "ABSENT";
|
||||
try {
|
||||
const originRoot = await navigator.storage.getDirectory();
|
||||
const runtimeRoot = await originRoot.getDirectoryHandle(
|
||||
DEFAULT_OPFS_RUNTIME_POLICY.rootDirectoryName,
|
||||
);
|
||||
const authorities = await runtimeRoot.getDirectoryHandle(
|
||||
"authorities",
|
||||
);
|
||||
await authorities.removeEntry(scope.authorityToken, {
|
||||
recursive: true,
|
||||
});
|
||||
opfsCleanup = "DELETED";
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException) || error.name !== "NotFoundError") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
nativeSupport: true as const,
|
||||
capabilities,
|
||||
put,
|
||||
opened:
|
||||
opened &&
|
||||
typeof opened === "object" &&
|
||||
"ok" in opened &&
|
||||
opened.ok
|
||||
? {
|
||||
ok: true,
|
||||
generation: opened.value.descriptor.generation,
|
||||
}
|
||||
: opened,
|
||||
readBytes,
|
||||
removed,
|
||||
afterRemove,
|
||||
reconciled,
|
||||
deletion,
|
||||
opfsCleanup,
|
||||
};
|
||||
});
|
||||
|
||||
if (!result.nativeSupport) {
|
||||
expect(result.capabilities).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNSUPPORTED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
expect(result.capabilities).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
available: true,
|
||||
dedicatedWorkerRequired: true,
|
||||
crossContextMutationLockAvailable: true,
|
||||
},
|
||||
});
|
||||
expect(result.put).toMatchObject({
|
||||
ok: true,
|
||||
value: { generation: 1, byteLength: 6 },
|
||||
});
|
||||
expect(result.opened).toEqual({ ok: true, generation: 1 });
|
||||
expect(result.readBytes).toEqual([3, 1, 4, 1, 5, 9]);
|
||||
expect(result.removed).toEqual({ ok: true });
|
||||
expect(result.afterRemove).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "NOT_FOUND" },
|
||||
});
|
||||
expect(result.reconciled).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
inspectedTransactions: 0,
|
||||
orphanGcStatus: "COMPLETED",
|
||||
},
|
||||
});
|
||||
expect(result.deletion).toBe("DELETED");
|
||||
expect(["DELETED", "ABSENT"]).toContain(result.opfsCleanup);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
startBrowserOpfsDedicatedWorker,
|
||||
type OpfsWorkerMessageHost,
|
||||
} from "../../src/adapters/storage/opfs/opfs-worker-runtime.ts";
|
||||
|
||||
void startBrowserOpfsDedicatedWorker(
|
||||
self as unknown as OpfsWorkerMessageHost,
|
||||
{
|
||||
storageManager: navigator.storage,
|
||||
lockManager: navigator.locks,
|
||||
crypto,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,366 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
const RESOURCE_ID = "browser-artifact-1";
|
||||
const OBJECT_ORIGIN = "https://objects.example.test";
|
||||
const OBJECT_PATH = "/files/browser-artifact-1";
|
||||
const OBJECT_HREF =
|
||||
`${OBJECT_ORIGIN}${OBJECT_PATH}?sig=opaque-browser-signature`;
|
||||
const DOWNLOAD_BYTES = Buffer.from([
|
||||
0x10, 0x20, 0x30, 0x40, 0x50,
|
||||
0x60, 0x70, 0x80, 0x90, 0xa0,
|
||||
]);
|
||||
const DOWNLOAD_SHA256 = createHash("sha256")
|
||||
.update(DOWNLOAD_BYTES)
|
||||
.digest("hex");
|
||||
|
||||
test("issues an opaque capability and streams a verified object into the writable save path", async ({
|
||||
page,
|
||||
}) => {
|
||||
const bffRequests: unknown[] = [];
|
||||
const objectRequests: Readonly<{
|
||||
url: string;
|
||||
method: string;
|
||||
accept: string | undefined;
|
||||
cookie: string | undefined;
|
||||
}>[] = [];
|
||||
|
||||
await page.route("**/api/download-capability", async (route) => {
|
||||
bffRequests.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
capabilityReceipt: "browser-download-capability-1",
|
||||
method: "GET",
|
||||
binding: {
|
||||
kind: "DOWNLOAD",
|
||||
resourceId: RESOURCE_ID,
|
||||
},
|
||||
href: OBJECT_HREF,
|
||||
origin: OBJECT_ORIGIN,
|
||||
path: OBJECT_PATH,
|
||||
allowedQueryParameters: ["sig"],
|
||||
requestHeaders: [
|
||||
{
|
||||
name: "accept",
|
||||
value: "application/octet-stream",
|
||||
},
|
||||
],
|
||||
requiredResponseHeaders: [
|
||||
{
|
||||
name: "x-policy-version",
|
||||
value: "v1",
|
||||
},
|
||||
],
|
||||
digestRequestHeader: null,
|
||||
digestResponseHeader: "x-content-sha256",
|
||||
receiptResponseHeader: null,
|
||||
expectedStatus: 200,
|
||||
expectedResponseByteLength: null,
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: DOWNLOAD_BYTES.byteLength,
|
||||
maxBytes: 64,
|
||||
expectedSha256: DOWNLOAD_SHA256,
|
||||
expiresAtEpochMs: Date.now() + 5 * 60_000,
|
||||
singleUse: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`${OBJECT_ORIGIN}/**`, async (route) => {
|
||||
const headers = route.request().headers();
|
||||
objectRequests.push({
|
||||
url: route.request().url(),
|
||||
method: route.request().method(),
|
||||
accept: headers.accept,
|
||||
cookie: headers.cookie,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: DOWNLOAD_BYTES,
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers":
|
||||
"content-length, x-content-sha256, x-policy-version",
|
||||
"content-length": String(DOWNLOAD_BYTES.byteLength),
|
||||
"content-type": "application/octet-stream",
|
||||
"x-content-sha256": DOWNLOAD_SHA256,
|
||||
"x-policy-version": "v1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(
|
||||
async ({ resourceId }) => {
|
||||
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 policyModulePath =
|
||||
"/src/adapters/browser-files/browser-file-policy-registry.ts";
|
||||
const deliveryModulePath =
|
||||
"/src/adapters/browser-files/download-delivery-adapter.ts";
|
||||
const [
|
||||
{
|
||||
createPresignedCapabilityVault,
|
||||
createSingleUsePresignedReplayGuard,
|
||||
},
|
||||
{ createPresignedCapabilityHttpProvider },
|
||||
{ createPresignedTransferExecutor },
|
||||
{
|
||||
BrowserFilePolicyRegistry,
|
||||
browserFilePolicyReference,
|
||||
},
|
||||
{ createDownloadDeliveryAdapter },
|
||||
] = await Promise.all([
|
||||
import(
|
||||
/* @vite-ignore */ vaultModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts")
|
||||
>,
|
||||
import(
|
||||
/* @vite-ignore */ providerModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts")
|
||||
>,
|
||||
import(
|
||||
/* @vite-ignore */ executorModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts")
|
||||
>,
|
||||
import(
|
||||
/* @vite-ignore */ policyModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-files/browser-file-policy-registry.ts")
|
||||
>,
|
||||
import(
|
||||
/* @vite-ignore */ deliveryModulePath
|
||||
) as Promise<
|
||||
typeof import("../../src/adapters/browser-files/download-delivery-adapter.ts")
|
||||
>,
|
||||
]);
|
||||
|
||||
const vault = createPresignedCapabilityVault({
|
||||
maxActiveCapabilities: 4,
|
||||
});
|
||||
const provider = createPresignedCapabilityHttpProvider({
|
||||
endpoint: `${location.origin}/api/download-capability`,
|
||||
vault,
|
||||
allowedDataOrigins: ["https://objects.example.test"],
|
||||
allowedDataPathPrefixes: ["/files/"],
|
||||
allowedQueryParameters: ["sig"],
|
||||
allowedRequestHeaders: ["accept"],
|
||||
allowedResponseHeaders: [
|
||||
"x-content-sha256",
|
||||
"x-policy-version",
|
||||
],
|
||||
hardMaxTransferBytes: 64,
|
||||
hardMaxUploadResponseBytes: 1_024,
|
||||
maxCapabilityTtlMs: 10 * 60_000,
|
||||
minimumRemainingLifetimeMs: 30_000,
|
||||
timeoutMs: 5_000,
|
||||
allowInsecureLocalhost: true,
|
||||
});
|
||||
const executor = createPresignedTransferExecutor({
|
||||
vault,
|
||||
replayGuard: createSingleUsePresignedReplayGuard(),
|
||||
hardMaxTransferBytes: 64,
|
||||
hardMaxChunkBytes: 3,
|
||||
hardMaxUploadResponseBytes: 1_024,
|
||||
minimumRemainingLifetimeMs: 30_000,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId,
|
||||
signal,
|
||||
});
|
||||
if (!issued.ok) {
|
||||
vault.dispose();
|
||||
return {
|
||||
issued,
|
||||
delivery: null,
|
||||
capabilityKeys: [],
|
||||
capabilityBindingKeys: [],
|
||||
written: [],
|
||||
writeChunkSizes: [],
|
||||
writableClosed: false,
|
||||
writableAborted: false,
|
||||
progressPhases: [],
|
||||
};
|
||||
}
|
||||
|
||||
const downloadPolicy = browserFilePolicyReference(
|
||||
"browser-presigned-download",
|
||||
"save-verified-artifact",
|
||||
);
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference: downloadPolicy,
|
||||
download: {
|
||||
strategy: "PROMPT_AND_STREAM",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 64,
|
||||
maxBufferedBytes: 8,
|
||||
integrity: "REQUIRED",
|
||||
},
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64,
|
||||
maxRetainedFileBytes: 64,
|
||||
maxPreviewBytes: 64,
|
||||
maxObjectUrlBytes: 64,
|
||||
maxTransferBytes: 64,
|
||||
},
|
||||
});
|
||||
const written: number[] = [];
|
||||
const writeChunkSizes: number[] = [];
|
||||
let writableClosed = false;
|
||||
let writableAborted = false;
|
||||
const downloads = createDownloadDeliveryAdapter({
|
||||
host: {
|
||||
handoff() {
|
||||
throw new TypeError(
|
||||
"Writable delivery must not use browser handoff.",
|
||||
);
|
||||
},
|
||||
},
|
||||
policies,
|
||||
hardMaxObjectUrlBytes: 64,
|
||||
hardMaxTransferBytes: 64,
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
throw new TypeError(
|
||||
"Browser-managed capability is not used.",
|
||||
);
|
||||
},
|
||||
},
|
||||
openAuthorizedSource:
|
||||
executor.downloadSources.open.bind(
|
||||
executor.downloadSources,
|
||||
),
|
||||
showSaveFilePicker: async () => ({
|
||||
async createWritable() {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
writeChunkSizes.push(chunk.byteLength);
|
||||
written.push(...chunk);
|
||||
},
|
||||
close() {
|
||||
writableClosed = true;
|
||||
},
|
||||
abort() {
|
||||
writableAborted = true;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createTransferId: () => "transfer:browser-presigned",
|
||||
progressMinIntervalMs: 0,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const progressPhases: string[] = [];
|
||||
const capabilityKeys = Object.keys(issued.value).sort();
|
||||
const capabilityBindingKeys = Object.keys(
|
||||
issued.value.binding,
|
||||
).sort();
|
||||
const delivery = await downloads.deliver({
|
||||
policy: downloadPolicy,
|
||||
source: {
|
||||
kind: "AUTHORIZED_STREAM_RESOURCE",
|
||||
resourceId,
|
||||
capability: issued.value,
|
||||
},
|
||||
suggestedFileName: "browser-artifact.bin",
|
||||
signal,
|
||||
onProgress(progress) {
|
||||
progressPhases.push(progress.phase);
|
||||
},
|
||||
});
|
||||
downloads.dispose();
|
||||
vault.dispose();
|
||||
return {
|
||||
issued: {
|
||||
ok: true as const,
|
||||
exposedHref: "href" in issued.value,
|
||||
exposedRequestHeaders: "requestHeaders" in issued.value,
|
||||
},
|
||||
delivery,
|
||||
capabilityKeys,
|
||||
capabilityBindingKeys,
|
||||
written,
|
||||
writeChunkSizes,
|
||||
writableClosed,
|
||||
writableAborted,
|
||||
progressPhases,
|
||||
};
|
||||
},
|
||||
{ resourceId: RESOURCE_ID },
|
||||
);
|
||||
|
||||
expect(bffRequests).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
binding: {
|
||||
kind: "DOWNLOAD",
|
||||
resourceId: RESOURCE_ID,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(objectRequests).toEqual([
|
||||
{
|
||||
url: OBJECT_HREF,
|
||||
method: "GET",
|
||||
accept: "application/octet-stream",
|
||||
cookie: undefined,
|
||||
},
|
||||
]);
|
||||
expect(result.issued).toEqual({
|
||||
ok: true,
|
||||
exposedHref: false,
|
||||
exposedRequestHeaders: false,
|
||||
});
|
||||
expect(result.capabilityKeys).toEqual([
|
||||
"binding",
|
||||
"byteLength",
|
||||
"capabilityReceipt",
|
||||
"expectedSha256",
|
||||
"expiresAtEpochMs",
|
||||
"maxBytes",
|
||||
"mediaType",
|
||||
"method",
|
||||
]);
|
||||
expect(result.capabilityBindingKeys).toEqual([
|
||||
"kind",
|
||||
"resourceId",
|
||||
]);
|
||||
expect(result.delivery).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "SAVED",
|
||||
transferId: "transfer:browser-presigned",
|
||||
bytesWritten: DOWNLOAD_BYTES.byteLength,
|
||||
integrity: "VERIFIED",
|
||||
},
|
||||
});
|
||||
expect(result.written).toEqual([...DOWNLOAD_BYTES]);
|
||||
expect(result.writeChunkSizes).toEqual([3, 3, 3, 1]);
|
||||
expect(result.writableClosed).toBe(true);
|
||||
expect(result.writableAborted).toBe(false);
|
||||
expect(result.progressPhases).toEqual([
|
||||
"PREPARING",
|
||||
"TRANSFERRING",
|
||||
"TRANSFERRING",
|
||||
"TRANSFERRING",
|
||||
"TRANSFERRING",
|
||||
"VERIFYING",
|
||||
"FINALIZING",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("keeps A active across a failed B stage, activates B with A rollback, and cleans only owned stale caches", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const adapterModulePath =
|
||||
"/src/adapters/cache-storage/public-response-cache-adapter.ts";
|
||||
const policyModulePath =
|
||||
"/src/adapters/cache-storage/public-cache-policy.ts";
|
||||
const {
|
||||
computePublicCacheManifestDigestHex,
|
||||
createPublicCacheWebLock,
|
||||
createPublicResponseCacheAdapter,
|
||||
} = (await import(
|
||||
/* @vite-ignore */ adapterModulePath
|
||||
)) as typeof import("../../src/adapters/cache-storage/public-response-cache-adapter.ts");
|
||||
const { createDefaultPublicCachePolicy } = (await import(
|
||||
/* @vite-ignore */ policyModulePath
|
||||
)) as typeof import("../../src/adapters/cache-storage/public-cache-policy.ts");
|
||||
|
||||
const suffix = crypto.randomUUID().replaceAll("-", "");
|
||||
const releaseAId = `browser-a-${suffix.slice(0, 20)}`;
|
||||
const releaseBId = `browser-b-${suffix.slice(0, 20)}`;
|
||||
const unrelatedName = `unrelated-${suffix}`;
|
||||
const policy = {
|
||||
...createDefaultPublicCachePolicy(location.origin),
|
||||
ownedCachePrefix: `cap-${suffix.slice(0, 24)}:`,
|
||||
mutationLockName: `cap-${suffix}:mutation`,
|
||||
};
|
||||
const payloads = {
|
||||
[`${location.origin}/cache-${suffix}-a.js`]: {
|
||||
bytes: new TextEncoder().encode("verified-release-a"),
|
||||
contentType: "application/javascript",
|
||||
},
|
||||
[`${location.origin}/cache-${suffix}-b.js`]: {
|
||||
bytes: new TextEncoder().encode("verified-release-b"),
|
||||
contentType: "application/javascript",
|
||||
},
|
||||
[`${location.origin}/cache-${suffix}-b.css`]: {
|
||||
bytes: new TextEncoder().encode(".release-b{display:block}"),
|
||||
contentType: "text/css",
|
||||
},
|
||||
} as const;
|
||||
const [aUrl, bUrl, bSecondUrl] = Object.keys(payloads);
|
||||
|
||||
const digestHex = async (bytes: Uint8Array): Promise<string> => {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
Uint8Array.from(bytes),
|
||||
);
|
||||
return Array.from(
|
||||
new Uint8Array(digest),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
};
|
||||
const asset = async (absoluteUrl: string) => {
|
||||
const payload = payloads[absoluteUrl as keyof typeof payloads];
|
||||
return {
|
||||
absoluteUrl,
|
||||
expectedByteLength: payload.bytes.byteLength,
|
||||
expectedContentType: payload.contentType,
|
||||
integrity: {
|
||||
algorithm: "SHA-256" as const,
|
||||
digestHex: await digestHex(payload.bytes),
|
||||
},
|
||||
};
|
||||
};
|
||||
const assetsA = [await asset(aUrl!)];
|
||||
const assetsB = [
|
||||
await asset(bUrl!),
|
||||
await asset(bSecondUrl!),
|
||||
];
|
||||
const manifestA = {
|
||||
releaseRegistryId: releaseAId,
|
||||
assets: assetsA,
|
||||
manifestDigestHex:
|
||||
await computePublicCacheManifestDigestHex(
|
||||
crypto,
|
||||
releaseAId,
|
||||
assetsA,
|
||||
policy,
|
||||
),
|
||||
};
|
||||
const manifestB = {
|
||||
releaseRegistryId: releaseBId,
|
||||
assets: assetsB,
|
||||
manifestDigestHex:
|
||||
await computePublicCacheManifestDigestHex(
|
||||
crypto,
|
||||
releaseBId,
|
||||
assetsB,
|
||||
policy,
|
||||
),
|
||||
};
|
||||
let corruptSecondBAsset = true;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: caches,
|
||||
crypto,
|
||||
mutationLock: createPublicCacheWebLock(
|
||||
navigator.locks,
|
||||
policy.mutationLockName,
|
||||
),
|
||||
policy,
|
||||
fetcher: async (request) => {
|
||||
if (request.signal.aborted) {
|
||||
throw new DOMException("Aborted.", "AbortError");
|
||||
}
|
||||
const payload =
|
||||
payloads[request.url as keyof typeof payloads];
|
||||
if (!payload) throw new TypeError("Unexpected cache URL.");
|
||||
const responseBytes =
|
||||
corruptSecondBAsset && request.url === bSecondUrl
|
||||
? new Uint8Array(payload.bytes.byteLength)
|
||||
: Uint8Array.from(payload.bytes);
|
||||
return new Response(responseBytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control":
|
||||
"public, max-age=31536000, immutable",
|
||||
"content-type": payload.contentType,
|
||||
"x-account-id": "must-not-persist",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const readActive = async (absoluteUrl: string) => {
|
||||
const matched = await adapter.responses.matchActiveExact({
|
||||
absoluteUrl,
|
||||
});
|
||||
if (!matched.ok || !matched.value) return matched;
|
||||
const streamed: number[] = [];
|
||||
for await (const chunk of matched.value.body.stream(
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
if (!chunk.ok) return chunk;
|
||||
streamed.push(...chunk.value);
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
streamed,
|
||||
headerNames: matched.value.headers.map(([name]) => name),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const stageA = await adapter.admin.stageRelease(manifestA);
|
||||
const activateA = await adapter.admin.activateRelease(
|
||||
manifestA.releaseRegistryId,
|
||||
manifestA.manifestDigestHex,
|
||||
);
|
||||
const matchedABeforeFailedB = await readActive(aUrl!);
|
||||
|
||||
const failedStageB =
|
||||
await adapter.admin.stageRelease(manifestB);
|
||||
const afterFailedB = await adapter.admin.inspect();
|
||||
const matchedAAfterFailedB = await readActive(aUrl!);
|
||||
|
||||
corruptSecondBAsset = false;
|
||||
const stageB = await adapter.admin.stageRelease(manifestB);
|
||||
const activateB = await adapter.admin.activateRelease(
|
||||
manifestB.releaseRegistryId,
|
||||
manifestB.manifestDigestHex,
|
||||
);
|
||||
const matchedB = await readActive(bUrl!);
|
||||
const afterActivateB = await adapter.admin.inspect();
|
||||
|
||||
const releaseBCacheName = (await caches.keys()).find((name) =>
|
||||
name.startsWith(
|
||||
`${policy.ownedCachePrefix}release:${releaseBId}:`,
|
||||
),
|
||||
);
|
||||
if (!releaseBCacheName) {
|
||||
throw new Error("B release cache not found.");
|
||||
}
|
||||
const releaseBCache = await caches.open(releaseBCacheName);
|
||||
await releaseBCache.put(
|
||||
new Request(bUrl!),
|
||||
new Response(new Uint8Array([0]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const poisoned = await adapter.responses.matchActiveExact({
|
||||
absoluteUrl: bUrl!,
|
||||
});
|
||||
|
||||
await caches.open(`${policy.ownedCachePrefix}stale`);
|
||||
await caches.open(unrelatedName);
|
||||
const cleanup = await adapter.admin.cleanupOwned();
|
||||
const names = await caches.keys();
|
||||
return {
|
||||
stageA,
|
||||
activateA,
|
||||
matchedABeforeFailedB,
|
||||
failedStageB,
|
||||
afterFailedB,
|
||||
matchedAAfterFailedB,
|
||||
stageB,
|
||||
activateB,
|
||||
matchedB,
|
||||
afterActivateB,
|
||||
poisoned,
|
||||
cleanup,
|
||||
unrelatedPreserved: names.includes(unrelatedName),
|
||||
activeBPreserved: names.some((name) =>
|
||||
name.startsWith(
|
||||
`${policy.ownedCachePrefix}release:${releaseBId}:`,
|
||||
),
|
||||
),
|
||||
rollbackAPreserved: names.some((name) =>
|
||||
name.startsWith(
|
||||
`${policy.ownedCachePrefix}release:${releaseAId}:`,
|
||||
),
|
||||
),
|
||||
remainingOwnedCount: names.filter((name) =>
|
||||
name.startsWith(policy.ownedCachePrefix),
|
||||
).length,
|
||||
expectedABytes: [...payloads[aUrl! as keyof typeof payloads].bytes],
|
||||
expectedBBytes: [...payloads[bUrl! as keyof typeof payloads].bytes],
|
||||
};
|
||||
} finally {
|
||||
for (const name of await caches.keys()) {
|
||||
if (
|
||||
name.startsWith(policy.ownedCachePrefix) ||
|
||||
name === unrelatedName
|
||||
) {
|
||||
await caches.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.stageA).toMatchObject({
|
||||
ok: true,
|
||||
value: { entryCount: 1 },
|
||||
});
|
||||
expect(result.activateA).toMatchObject({ ok: true });
|
||||
expect(result.matchedABeforeFailedB).toMatchObject({
|
||||
ok: true,
|
||||
streamed: result.expectedABytes,
|
||||
});
|
||||
expect(result.failedStageB).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTEGRITY_FAILED" },
|
||||
});
|
||||
expect(result.afterFailedB).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
activeReleaseRegistryId: expect.stringMatching(/^browser-a-/u),
|
||||
releaseCandidates: [
|
||||
expect.objectContaining({
|
||||
releaseRegistryId: expect.stringMatching(/^browser-a-/u),
|
||||
verified: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.matchedAAfterFailedB).toMatchObject({
|
||||
ok: true,
|
||||
streamed: result.expectedABytes,
|
||||
});
|
||||
expect(result.stageB).toMatchObject({
|
||||
ok: true,
|
||||
value: { entryCount: 2 },
|
||||
});
|
||||
expect(result.activateB).toMatchObject({ ok: true });
|
||||
expect(result.matchedB).toMatchObject({
|
||||
ok: true,
|
||||
streamed: result.expectedBBytes,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
result.matchedB as Readonly<{
|
||||
headerNames: readonly string[];
|
||||
}>
|
||||
).headerNames,
|
||||
).not.toContain("x-account-id");
|
||||
expect(result.afterActivateB).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
activeReleaseRegistryId: expect.stringMatching(/^browser-b-/u),
|
||||
releaseCandidates: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
releaseRegistryId: expect.stringMatching(/^browser-a-/u),
|
||||
verified: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
releaseRegistryId: expect.stringMatching(/^browser-b-/u),
|
||||
verified: true,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(result.poisoned).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTEGRITY_FAILED" },
|
||||
});
|
||||
expect(result.cleanup).toMatchObject({
|
||||
ok: true,
|
||||
value: { deletedOwnedCaches: 1, retainedOwnedCaches: 3 },
|
||||
});
|
||||
expect(result.unrelatedPreserved).toBe(true);
|
||||
expect(result.activeBPreserved).toBe(true);
|
||||
expect(result.rollbackAPreserved).toBe(true);
|
||||
expect(result.remainingOwnedCount).toBe(3);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||
|
||||
test("executes the storage durability adapter against the real browser StorageManager", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const modulePath =
|
||||
"/src/adapters/browser-file-storage/storage-manager-adapter.ts";
|
||||
const { createStorageDurabilityAdapter } = await import(
|
||||
/* @vite-ignore */ modulePath
|
||||
);
|
||||
const adapter = createStorageDurabilityAdapter(navigator.storage);
|
||||
return adapter.inspect();
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain(
|
||||
result.value.pressure,
|
||||
);
|
||||
expect(
|
||||
result.value.usageBytes === null || result.value.usageBytes >= 0,
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.value.quotaBytes === null || result.value.quotaBytes >= 0,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user