refactor: 프론트엔드 리펙토링
This commit is contained in:
@@ -10,135 +10,17 @@ import {
|
||||
type PublicCacheRuntimePolicy,
|
||||
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
||||
import {
|
||||
computePublicCacheManifestDigestHex,
|
||||
createPublicResponseCacheAdapter,
|
||||
type PublicCacheMutationLock,
|
||||
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
|
||||
|
||||
class MemoryCache {
|
||||
readonly responses: Array<Readonly<{
|
||||
request: Request;
|
||||
response: Response;
|
||||
}>> = [];
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
const url =
|
||||
request instanceof Request ? request.url : new URL(String(request)).href;
|
||||
const nativeRequest =
|
||||
request instanceof Request ? request : new Request(url);
|
||||
return this.responses
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.request.url === url &&
|
||||
varyMatches(entry.request, nativeRequest, entry.response),
|
||||
)
|
||||
?.response.clone();
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
const url =
|
||||
request instanceof Request ? request.url : new URL(String(request)).href;
|
||||
const nativeRequest =
|
||||
request instanceof Request ? request.clone() : new Request(url);
|
||||
const existing = this.responses.findIndex(
|
||||
(entry) =>
|
||||
entry.request.url === url &&
|
||||
varyMatches(entry.request, nativeRequest, response),
|
||||
);
|
||||
const entry = {
|
||||
request: nativeRequest,
|
||||
response: response.clone(),
|
||||
};
|
||||
if (existing >= 0) this.responses.splice(existing, 1, entry);
|
||||
else this.responses.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function varyMatches(
|
||||
storedRequest: Request,
|
||||
incomingRequest: Request,
|
||||
response: Response,
|
||||
): boolean {
|
||||
const vary = response.headers.get("vary");
|
||||
if (!vary) return true;
|
||||
return vary
|
||||
.split(",")
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.every(
|
||||
(name) =>
|
||||
storedRequest.headers.get(name) ===
|
||||
incomingRequest.headers.get(name),
|
||||
);
|
||||
}
|
||||
|
||||
class MemoryCacheStorage {
|
||||
readonly caches = new Map<string, MemoryCache>();
|
||||
|
||||
async open(name: string): Promise<Cache> {
|
||||
let cache = this.caches.get(name);
|
||||
if (!cache) {
|
||||
cache = new MemoryCache();
|
||||
this.caches.set(name, cache);
|
||||
}
|
||||
return cache as unknown as Cache;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
return [...this.caches.keys()];
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.caches.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const immediateLock: PublicCacheMutationLock = {
|
||||
async run(_signal, task) {
|
||||
return await task();
|
||||
},
|
||||
};
|
||||
|
||||
function deferred<Value>() {
|
||||
let settle: ((value: Value) => void) | undefined;
|
||||
const promise = new Promise<Value>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return Object.freeze({
|
||||
promise,
|
||||
resolve(value: Value): void {
|
||||
settle?.(value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function digestHex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
Uint8Array.from(bytes),
|
||||
);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function manifestFor(
|
||||
releaseRegistryId: string,
|
||||
assets: readonly PublicCacheAsset[],
|
||||
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
),
|
||||
): Promise<PublicCacheReleaseManifest> {
|
||||
return {
|
||||
releaseRegistryId,
|
||||
assets,
|
||||
manifestDigestHex: await computePublicCacheManifestDigestHex(
|
||||
globalThis.crypto,
|
||||
releaseRegistryId,
|
||||
assets,
|
||||
policy,
|
||||
),
|
||||
};
|
||||
}
|
||||
import {
|
||||
MemoryCacheStorage,
|
||||
deferred,
|
||||
digestHex,
|
||||
immediateLock,
|
||||
manifestFor,
|
||||
} from "./public-response-cache-fixture.ts";
|
||||
|
||||
describe("public response Cache Storage adapter", () => {
|
||||
it("rejects a policy that enables variants but strips Vary", () => {
|
||||
@@ -1513,199 +1395,3 @@ describe("public response Cache Storage adapter", () => {
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
|
||||
* the last thing a repair may destroy. A transient marker read failure is not
|
||||
* evidence of damage, and a repair that has not yet fetched anything has not
|
||||
* yet earned the right to delete what still works.
|
||||
*/
|
||||
describe("public response cache repair is failure-atomic", () => {
|
||||
async function stagedRelease(releaseRegistryId: string) {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const firstBytes = new Uint8Array([1, 1, 1, 1]);
|
||||
const secondBytes = new Uint8Array([2, 2, 2, 2]);
|
||||
const assets: readonly PublicCacheAsset[] = [
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/first.js",
|
||||
expectedByteLength: firstBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(firstBytes),
|
||||
},
|
||||
},
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/second.js",
|
||||
expectedByteLength: secondBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(secondBytes),
|
||||
},
|
||||
},
|
||||
];
|
||||
const bodies = new Map<string, Uint8Array>([
|
||||
[assets[0]!.absoluteUrl, firstBytes],
|
||||
[assets[1]!.absoluteUrl, secondBytes],
|
||||
]);
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const fetchLog: string[] = [];
|
||||
let failFrom: string | null = null;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async (request: Request) => {
|
||||
fetchLog.push(request.url);
|
||||
if (failFrom !== null && request.url === failFrom) {
|
||||
throw new TypeError("network is down");
|
||||
}
|
||||
const body = bodies.get(request.url);
|
||||
if (!body) throw new TypeError(`unknown asset ${request.url}`);
|
||||
return new Response(Uint8Array.from(body), {
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor(releaseRegistryId, assets, policy);
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(
|
||||
await adapter.admin.activateRelease(
|
||||
manifest.releaseRegistryId,
|
||||
manifest.manifestDigestHex,
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
|
||||
name.includes(releaseRegistryId),
|
||||
);
|
||||
if (!cacheName) throw new Error("staged cache missing");
|
||||
|
||||
return {
|
||||
adapter,
|
||||
assets,
|
||||
cacheName,
|
||||
cacheStorage,
|
||||
fetchLog,
|
||||
manifest,
|
||||
setFailure(url: string | null) {
|
||||
failFrom = url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("does not delete an active candidate when the marker read fails transiently", async () => {
|
||||
const release = await stagedRelease("transient-marker");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const realMatch = cache.match.bind(cache);
|
||||
let markerReads = 0;
|
||||
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
|
||||
cache.match = async (request: RequestInfo | URL) => {
|
||||
const url =
|
||||
request instanceof Request ? request.url : String(request);
|
||||
if (!assetUrls.has(url)) {
|
||||
markerReads += 1;
|
||||
throw new DOMException("Storage is busy", "InvalidStateError");
|
||||
}
|
||||
return await realMatch(request);
|
||||
};
|
||||
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
|
||||
expect(markerReads).toBeGreaterThan(0);
|
||||
expect(restaged.ok).toBe(false);
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
cache.match = realMatch;
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps every healthy asset when one repair fetch fails", async () => {
|
||||
const release = await stagedRelease("partial-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
// Corrupt only the first asset's stored bytes.
|
||||
const corrupted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
|
||||
);
|
||||
expect(corrupted).toBeGreaterThanOrEqual(0);
|
||||
cache.responses.splice(corrupted, 1);
|
||||
|
||||
release.setFailure(release.assets[0]!.absoluteUrl);
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
expect(restaged.ok).toBe(false);
|
||||
|
||||
// The cache still exists and the healthy asset is still served.
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("still removes a candidate this call created when staging fails", async () => {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([7, 7, 7, 7]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/fresh.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(bytes),
|
||||
},
|
||||
};
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () => {
|
||||
throw new TypeError("network is down");
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor("fresh-release", [asset], policy);
|
||||
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("repairs an evicted asset in place and keeps the release usable", async () => {
|
||||
const release = await stagedRelease("in-place-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const evicted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
|
||||
);
|
||||
cache.responses.splice(evicted, 1);
|
||||
|
||||
expect(
|
||||
await release.adapter.admin.stageRelease(release.manifest),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user